filippo.io/age/age.go 90.2%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 // Package age implements file encryption according to the age-encryption.org/v1
6 // specification.
7 //
8 // For most use cases, use the [Encrypt] and [Decrypt] functions with
9 // [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
10 // required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
11 // existing SSH keys use the filippo.io/age/agessh package.
12 //
13 // age encrypted files are binary and not malleable. For encoding them as text,
14 // use the filippo.io/age/armor package.
15 //
16 // # Key management
17 //
18 // age does not have a global keyring. Instead, since age keys are small,
19 // textual, and cheap, you are encouraged to generate dedicated keys for each
20 // task and application.
21 //
22 // Recipient public keys can be passed around as command line flags and in
23 // config files, while secret keys should be stored in dedicated files, through
24 // secret management systems, or as environment variables.
25 //
26 // There is no default path for age keys. Instead, they should be stored at
27 // application-specific paths. The CLI supports files where private keys are
28 // listed one per line, ignoring empty lines and lines starting with "#". These
29 // files can be parsed with [ParseIdentities].
30 //
31 // When integrating age into a new system, it's recommended that you only
32 // support native (X25519 and hybrid) keys, and not SSH keys. The latter are
33 // supported for manual encryption operations. If you need to tie into existing
34 // key management infrastructure, you might want to consider implementing your
35 // own [Recipient] and [Identity].
36 //
37 // # Backwards compatibility
38 //
39 // Files encrypted with a stable version (not alpha, beta, or release candidate)
40 // of age, or with any v1.0.0 beta or release candidate, will decrypt with any
41 // later versions of the v1 API. This might change in v2, in which case v1 will
42 // be maintained with security fixes for compatibility with older files.
43 //
44 // If decrypting an older file poses a security risk, doing so might require an
45 // explicit opt-in in the API.
46 package age
47
48 import (
49 "bytes"
50 "crypto/hmac"
51 "crypto/rand"
52 "errors"
53 "fmt"
54 "io"
55 "slices"
56 "sort"
57
58 "filippo.io/age/internal/format"
59 "filippo.io/age/internal/stream"
60 )
61
62 // An Identity is passed to [Decrypt] to unwrap an opaque file key from a
63 // recipient stanza. It can be for example a secret key like [HybridIdentity], a
64 // plugin, or a custom implementation.
65 type Identity interface {
66 // Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
67 // the recipient stanzas match the identity, any other error will be
68 // considered fatal.
69 //
70 // Most age API users won't need to interact with this method directly, and
71 // should instead pass [Identity] implementations to [Decrypt].
72 Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
73 }
74
75 // ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the
76 // recipient stanzas match the identity.
77 var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
78
79 // A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
80 // recipient stanza(s). It can be for example a public key like [HybridRecipient],
81 // a plugin, or a custom implementation.
82 type Recipient interface {
83 // Most age API users won't need to interact with this method directly, and
84 // should instead pass [Recipient] implementations to [Encrypt].
85 Wrap(fileKey []byte) ([]*Stanza, error)
86 }
87
88 // RecipientWithLabels can be optionally implemented by a [Recipient], in which
89 // case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap].
90 //
91 // Encrypt will succeed only if the labels returned by all the recipients
92 // (assuming the empty set for those that don't implement RecipientWithLabels)
93 // are the same.
94 //
95 // This can be used to ensure a recipient is only used with other recipients
96 // with equivalent properties (for example by setting a "postquantum" label) or
97 // to ensure a recipient is always used alone (by returning a random label, for
98 // example to preserve its authentication properties).
99 type RecipientWithLabels interface {
100 WrapWithLabels(fileKey []byte) (s []*Stanza, labels []string, err error)
101 }
102
103 // A Stanza is a section of the age header that encapsulates the file key as
104 // encrypted to a specific recipient.
105 //
106 // Most age API users won't need to interact with this type directly, and should
107 // instead pass [Recipient] implementations to [Encrypt] and [Identity]
108 // implementations to [Decrypt].
109 type Stanza struct {
110 Type string
111 Args []string
112 Body []byte
113 }
114
115 const fileKeySize = 16
116 const streamNonceSize = 16
117
118
func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error) {
119
if len(recipients) == 0 {
120
return nil, errors.New("no recipients specified")
121
}
122
123
hdr := &format.Header{}
124
var labels []string
125
for i, r := range recipients {
126
stanzas, l, err := wrapWithLabels(r, fileKey)
127
if err != nil {
128
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %w", i, err)
129
}
130
sort.Strings(l)
131
if i == 0 {
132
labels = l
133
} else if !slicesEqual(labels, l) {
134
return nil, incompatibleLabelsError(labels, l)
135
}
136
for _, s := range stanzas {
137
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
138
}
139 }
140
if mac, err := headerMAC(fileKey, hdr); err != nil {
141
return nil, fmt.Errorf("failed to compute header MAC: %v", err)
142
} else {
143
hdr.MAC = mac
144
}
145
return hdr, nil
146 }
147
148 // Encrypt encrypts a file to one or more recipients. Every recipient will be
149 // able to decrypt the file.
150 //
151 // Writes to the returned WriteCloser are encrypted and written to dst as an age
152 // file. The caller must call Close on the WriteCloser when done for the last
153 // chunk to be encrypted and flushed to dst.
154
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
155
fileKey := make([]byte, fileKeySize)
156
rand.Read(fileKey)
157
158
hdr, err := encryptHdr(fileKey, recipients...)
159
if err != nil {
160
return nil, err
161
}
162
if err := hdr.Marshal(dst); err != nil {
163
return nil, fmt.Errorf("failed to write header: %w", err)
164
}
165
166
nonce := make([]byte, streamNonceSize)
167
rand.Read(nonce)
168
if _, err := dst.Write(nonce); err != nil {
169
return nil, fmt.Errorf("failed to write nonce: %w", err)
170
}
171
172
return stream.NewEncryptWriter(streamKey(fileKey, nonce), dst)
173 }
174
175 // EncryptReader encrypts a file to one or more recipients. Every recipient will be
176 // able to decrypt the file.
177 //
178 // Reads from the returned Reader produce the encrypted file, where the plaintext
179 // is read from src.
180
func EncryptReader(src io.Reader, recipients ...Recipient) (io.Reader, error) {
181
fileKey := make([]byte, fileKeySize)
182
rand.Read(fileKey)
183
184
hdr, err := encryptHdr(fileKey, recipients...)
185
if err != nil {
186
return nil, err
187
}
188
buf := &bytes.Buffer{}
189
if err := hdr.Marshal(buf); err != nil {
190
return nil, fmt.Errorf("failed to prepare header: %w", err)
191
}
192
193
nonce := make([]byte, streamNonceSize)
194
rand.Read(nonce)
195
196
r, err := stream.NewEncryptReader(streamKey(fileKey, nonce), src)
197
if err != nil {
198
return nil, err
199
}
200
return io.MultiReader(buf, bytes.NewReader(nonce), r), nil
201 }
202
203
func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
204
if r, ok := r.(RecipientWithLabels); ok {
205
return r.WrapWithLabels(fileKey)
206
}
207
s, err = r.Wrap(fileKey)
208
return
209 }
210
211
func slicesEqual(s1, s2 []string) bool {
212
if len(s1) != len(s2) {
213
return false
214
}
215
for i := range s1 {
216
if s1[i] != s2[i] {
217
return false
218
}
219 }
220
return true
221 }
222
223
func incompatibleLabelsError(l1, l2 []string) error {
224
hasPQ1 := slices.Contains(l1, "postquantum")
225
hasPQ2 := slices.Contains(l2, "postquantum")
226
if hasPQ1 != hasPQ2 {
227
return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
228
}
229
return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
230 }
231
232 // NoIdentityMatchError is returned by [Decrypt] when none of the supplied
233 // identities match the encrypted file.
234 type NoIdentityMatchError struct {
235 // Errors is a slice of all the errors returned to Decrypt by the Unwrap
236 // calls it made. They all wrap [ErrIncorrectIdentity].
237 Errors []error
238 // StanzaTypes are the first argument of each recipient stanza in the
239 // encrypted file's header.
240 StanzaTypes []string
241 }
242
243
func (e *NoIdentityMatchError) Error() string {
244
if len(e.Errors) == 1 {
245
return "identity did not match any of the recipients: " + e.Errors[0].Error()
246
}
247
return "no identity matched any of the recipients"
248 }
249
250
func (e *NoIdentityMatchError) Unwrap() []error {
251
return e.Errors
252
}
253
254 // Decrypt decrypts a file encrypted to one or more identities.
255 // All identities will be tried until one successfully decrypts the file.
256 // Native, non-interactive identities are tried before any other identities.
257 //
258 // Decrypt returns a Reader reading the decrypted plaintext of the age file read
259 // from src. If no identity matches the encrypted file, the returned error will
260 // be of type [NoIdentityMatchError].
261
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
262
hdr, payload, err := format.Parse(src)
263
if err != nil {
264
return nil, fmt.Errorf("failed to read header: %w", err)
265
}
266
267
fileKey, err := decryptHdr(hdr, identities...)
268
if err != nil {
269
return nil, err
270
}
271
272
nonce := make([]byte, streamNonceSize)
273
if _, err := io.ReadFull(payload, nonce); err != nil {
274
return nil, fmt.Errorf("failed to read nonce: %w", err)
275
}
276
277
return stream.NewDecryptReader(streamKey(fileKey, nonce), payload)
278 }
279
280 // DecryptReaderAt decrypts a file encrypted to one or more identities.
281 // All identities will be tried until one successfully decrypts the file.
282 // Native, non-interactive identities are tried before any other identities.
283 //
284 // DecryptReaderAt takes an underlying [io.ReaderAt] and its total encrypted
285 // size, and returns a ReaderAt of the decrypted plaintext and the plaintext
286 // size. These can be used for example to instantiate an [io.SectionReader],
287 // which implements [io.Reader] and [io.Seeker], or for [zip.NewReader].
288 // Note that ReaderAt by definition disregards the seek position of src.
289 //
290 // The ReadAt method of the returned ReaderAt can be called concurrently.
291 // The ReaderAt will internally cache the most recently decrypted chunk.
292 // DecryptReaderAt reads and decrypts the final chunk before returning,
293 // to authenticate the plaintext size.
294 //
295 // If no identity matches the encrypted file, the returned error will be of
296 // type [NoIdentityMatchError].
297
func DecryptReaderAt(src io.ReaderAt, encryptedSize int64, identities ...Identity) (io.ReaderAt, int64, error) {
298
srcReader := io.NewSectionReader(src, 0, encryptedSize)
299
hdr, payload, err := format.Parse(srcReader)
300
if err != nil {
301
return nil, 0, fmt.Errorf("failed to read header: %w", err)
302
}
303
buf := &bytes.Buffer{}
304
if err := hdr.Marshal(buf); err != nil {
305
return nil, 0, fmt.Errorf("failed to serialize header: %w", err)
306
}
307
308
fileKey, err := decryptHdr(hdr, identities...)
309
if err != nil {
310
return nil, 0, err
311
}
312
313
nonce := make([]byte, streamNonceSize)
314
if _, err := io.ReadFull(payload, nonce); err != nil {
315
return nil, 0, fmt.Errorf("failed to read nonce: %w", err)
316
}
317
318
payloadOffset := int64(buf.Len()) + int64(len(nonce))
319
payloadSize := encryptedSize - payloadOffset
320
plaintextSize, err := stream.PlaintextSize(payloadSize)
321
if err != nil {
322
return nil, 0, err
323
}
324
payloadReaderAt := io.NewSectionReader(src, payloadOffset, payloadSize)
325
r, err := stream.NewDecryptReaderAt(streamKey(fileKey, nonce), payloadReaderAt, payloadSize)
326
if err != nil {
327
return nil, 0, err
328
}
329
return r, plaintextSize, nil
330 }
331
332
func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
333
if len(identities) == 0 {
334
return nil, errors.New("no identities specified")
335
}
336
slices.SortStableFunc(identities, func(a, b Identity) int {
337
var aIsNative, bIsNative bool
338
switch a.(type) {
339
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
340
aIsNative = true
341 }
342
switch b.(type) {
343
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
344
bIsNative = true
345 }
346
if aIsNative && !bIsNative {
347
return -1
348
}
349
if !aIsNative && bIsNative {
350
return 1
351
}
352
return 0
353 })
354
355
stanzas := make([]*Stanza, 0, len(hdr.Recipients))
356
errNoMatch := &NoIdentityMatchError{}
357
for _, s := range hdr.Recipients {
358
errNoMatch.StanzaTypes = append(errNoMatch.StanzaTypes, s.Type)
359
stanzas = append(stanzas, (*Stanza)(s))
360
}
361
var fileKey []byte
362
for _, id := range identities {
363
var err error
364
fileKey, err = id.Unwrap(stanzas)
365
if errors.Is(err, ErrIncorrectIdentity) {
366
errNoMatch.Errors = append(errNoMatch.Errors, err)
367
continue
368 }
369
if err != nil {
370
return nil, err
371
}
372
373
break
374 }
375
if fileKey == nil {
376
return nil, errNoMatch
377
}
378
379
if mac, err := headerMAC(fileKey, hdr); err != nil {
380
return nil, fmt.Errorf("failed to compute header MAC: %v", err)
381
} else if !hmac.Equal(mac, hdr.MAC) {
382
return nil, errors.New("bad header MAC")
383
}
384
385
return fileKey, nil
386 }
387
388 // multiUnwrap is a helper that implements Identity.Unwrap in terms of a
389 // function that unwraps a single recipient stanza.
390
func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byte, error) {
391
for _, s := range stanzas {
392
fileKey, err := unwrap(s)
393
if errors.Is(err, ErrIncorrectIdentity) {
394
// If we ever start returning something interesting wrapping
395
// ErrIncorrectIdentity, we should let it make its way up through
396
// Decrypt into NoIdentityMatchError.Errors.
397
continue
398 }
399
if err != nil {
400
return nil, err
401
}
402
return fileKey, nil
403 }
404
return nil, ErrIncorrectIdentity
405 }
406
407 // ExtractHeader returns a detached header from the src file.
408 //
409 // The detached header can be decrypted with [DecryptHeader] (for example on a
410 // different system, without sharing the ciphertext) and then the file key can
411 // be used with [NewInjectedFileKeyIdentity].
412 //
413 // This is a low-level function that most users won't need.
414
func ExtractHeader(src io.Reader) ([]byte, error) {
415
hdr, _, err := format.Parse(src)
416
if err != nil {
417
return nil, fmt.Errorf("failed to read header: %w", err)
418
}
419
buf := &bytes.Buffer{}
420
if err := hdr.Marshal(buf); err != nil {
421
return nil, fmt.Errorf("failed to serialize header: %w", err)
422
}
423
return buf.Bytes(), nil
424 }
425
426 // DecryptHeader decrypts a detached header and returns a file key.
427 //
428 // The detached header can be produced by [ExtractHeader], and the
429 // returned file key can be used with [NewInjectedFileKeyIdentity].
430 //
431 // This is a low-level function that most users won't need.
432 // It is the caller's responsibility to keep track of what file the
433 // returned file key decrypts, and to ensure the file key is not used
434 // for any other purpose.
435
func DecryptHeader(header []byte, identities ...Identity) ([]byte, error) {
436
hdr, _, err := format.Parse(bytes.NewReader(header))
437
if err != nil {
438
return nil, fmt.Errorf("failed to read header: %w", err)
439
}
440
return decryptHdr(hdr, identities...)
441 }
442
443 type injectedFileKeyIdentity struct {
444 fileKey []byte
445 }
446
447 // NewInjectedFileKeyIdentity returns an [Identity] that always produces
448 // a fixed file key, allowing the use of a file key obtained out-of-band,
449 // for example via [DecryptHeader].
450 //
451 // This is a low-level function that most users won't need.
452
func NewInjectedFileKeyIdentity(fileKey []byte) Identity {
453
return injectedFileKeyIdentity{fileKey}
454
}
455
456
func (i injectedFileKeyIdentity) Unwrap(stanzas []*Stanza) (fileKey []byte, err error) {
457
return i.fileKey, nil
458
}
459
filippo.io/age/agessh/agessh.go 65.2%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 // Package agessh provides age.Identity and age.Recipient implementations of
6 // types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH keys for
7 // encryption with age-encryption.org/v1.
8 //
9 // These recipient types should only be used for compatibility with existing
10 // keys, and native keys should be preferred otherwise.
11 //
12 // Note that these recipient types are not anonymous: the encrypted message will
13 // include a short 32-bit ID of the public key.
14 package agessh
15
16 import (
17 "crypto/ed25519"
18 "crypto/rand"
19 "crypto/rsa"
20 "crypto/sha256"
21 "crypto/sha512"
22 "errors"
23 "fmt"
24 "io"
25
26 "filippo.io/age"
27 "filippo.io/age/internal/format"
28 "filippo.io/edwards25519"
29 "golang.org/x/crypto/chacha20poly1305"
30 "golang.org/x/crypto/curve25519"
31 "golang.org/x/crypto/hkdf"
32 "golang.org/x/crypto/ssh"
33 )
34
35
func sshFingerprint(pk ssh.PublicKey) string {
36
h := sha256.Sum256(pk.Marshal())
37
return format.EncodeToString(h[:4])
38
}
39
40 const oaepLabel = "age-encryption.org/v1/ssh-rsa"
41
42 type RSARecipient struct {
43 sshKey ssh.PublicKey
44 pubKey *rsa.PublicKey
45 }
46
47 var _ age.Recipient = &RSARecipient{}
48
49
func NewRSARecipient(pk ssh.PublicKey) (*RSARecipient, error) {
50
if pk.Type() != "ssh-rsa" {
51
return nil, errors.New("SSH public key is not an RSA key")
52
}
53
r := &RSARecipient{
54
sshKey: pk,
55
}
56
57
if pk, ok := pk.(ssh.CryptoPublicKey); ok {
58
if pk, ok := pk.CryptoPublicKey().(*rsa.PublicKey); ok {
59
r.pubKey = pk
60
} else {
61
return nil, errors.New("unexpected public key type")
62
}
63
} else {
64
return nil, errors.New("pk does not implement ssh.CryptoPublicKey")
65
}
66
if r.pubKey.Size() < 2048/8 {
67
return nil, errors.New("RSA key size is too small")
68
}
69
return r, nil
70 }
71
72
func (r *RSARecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
73
l := &age.Stanza{
74
Type: "ssh-rsa",
75
Args: []string{sshFingerprint(r.sshKey)},
76
}
77
78
wrappedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader,
79
r.pubKey, fileKey, []byte(oaepLabel))
80
if err != nil {
81
return nil, err
82
}
83
l.Body = wrappedKey
84
85
return []*age.Stanza{l}, nil
86 }
87
88 type RSAIdentity struct {
89 k *rsa.PrivateKey
90 sshKey ssh.PublicKey
91 }
92
93 var _ age.Identity = &RSAIdentity{}
94
95
func NewRSAIdentity(key *rsa.PrivateKey) (*RSAIdentity, error) {
96
s, err := ssh.NewSignerFromKey(key)
97
if err != nil {
98
return nil, err
99
}
100
i := &RSAIdentity{
101
k: key, sshKey: s.PublicKey(),
102
}
103
return i, nil
104 }
105
106
func (i *RSAIdentity) Recipient() *RSARecipient {
107
return &RSARecipient{
108
sshKey: i.sshKey,
109
pubKey: &i.k.PublicKey,
110
}
111
}
112
113
func (i *RSAIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
114
return multiUnwrap(i.unwrap, stanzas)
115
}
116
117
func (i *RSAIdentity) unwrap(block *age.Stanza) ([]byte, error) {
118
if block.Type != "ssh-rsa" {
119
return nil, age.ErrIncorrectIdentity
120
}
121
if len(block.Args) != 1 {
122
return nil, errors.New("invalid ssh-rsa recipient block")
123
}
124
125
if block.Args[0] != sshFingerprint(i.sshKey) {
126
return nil, age.ErrIncorrectIdentity
127
}
128
129
fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k,
130
block.Body, []byte(oaepLabel))
131
if err != nil {
132
return nil, fmt.Errorf("failed to decrypt file key: %v", err)
133
}
134
return fileKey, nil
135 }
136
137 type Ed25519Recipient struct {
138 sshKey ssh.PublicKey
139 theirPublicKey []byte
140 }
141
142 var _ age.Recipient = &Ed25519Recipient{}
143
144
func NewEd25519Recipient(pk ssh.PublicKey) (*Ed25519Recipient, error) {
145
if pk.Type() != "ssh-ed25519" {
146
return nil, errors.New("SSH public key is not an Ed25519 key")
147
}
148
149
cpk, ok := pk.(ssh.CryptoPublicKey)
150
if !ok {
151
return nil, errors.New("pk does not implement ssh.CryptoPublicKey")
152
}
153
epk, ok := cpk.CryptoPublicKey().(ed25519.PublicKey)
154
if !ok {
155
return nil, errors.New("unexpected public key type")
156
}
157
mpk, err := ed25519PublicKeyToCurve25519(epk)
158
if err != nil {
159
return nil, fmt.Errorf("invalid Ed25519 public key: %v", err)
160
}
161
162
return &Ed25519Recipient{
163
sshKey: pk,
164
theirPublicKey: mpk,
165
}, nil
166 }
167
168
func ParseRecipient(s string) (age.Recipient, error) {
169
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(s))
170
if err != nil {
171
return nil, fmt.Errorf("malformed SSH recipient: %q: %v", s, err)
172
}
173
174
var r age.Recipient
175
switch t := pubKey.Type(); t {
176
case "ssh-rsa":
177
r, err = NewRSARecipient(pubKey)
178
case "ssh-ed25519":
179
r, err = NewEd25519Recipient(pubKey)
180
default:
181
return nil, fmt.Errorf("unknown SSH recipient type: %q", t)
182 }
183
if err != nil {
184
return nil, fmt.Errorf("malformed SSH recipient: %q: %v", s, err)
185
}
186
187
return r, nil
188 }
189
190
func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) ([]byte, error) {
191
// See https://blog.filippo.io/using-ed25519-keys-for-encryption and
192
// https://pkg.go.dev/filippo.io/edwards25519#Point.BytesMontgomery.
193
p, err := new(edwards25519.Point).SetBytes(pk)
194
if err != nil {
195
return nil, err
196
}
197
return p.BytesMontgomery(), nil
198 }
199
200 const ed25519Label = "age-encryption.org/v1/ssh-ed25519"
201
202
func (r *Ed25519Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
203
ephemeral := make([]byte, curve25519.ScalarSize)
204
if _, err := rand.Read(ephemeral); err != nil {
205
return nil, err
206
}
207
ourPublicKey, err := curve25519.X25519(ephemeral, curve25519.Basepoint)
208
if err != nil {
209
return nil, err
210
}
211
212
sharedSecret, err := curve25519.X25519(ephemeral, r.theirPublicKey)
213
if err != nil {
214
return nil, err
215
}
216
217
tweak := make([]byte, curve25519.ScalarSize)
218
tH := hkdf.New(sha256.New, nil, r.sshKey.Marshal(), []byte(ed25519Label))
219
if _, err := io.ReadFull(tH, tweak); err != nil {
220
return nil, err
221
}
222
sharedSecret, _ = curve25519.X25519(tweak, sharedSecret)
223
224
l := &age.Stanza{
225
Type: "ssh-ed25519",
226
Args: []string{sshFingerprint(r.sshKey),
227
format.EncodeToString(ourPublicKey[:])},
228
}
229
230
salt := make([]byte, 0, len(ourPublicKey)+len(r.theirPublicKey))
231
salt = append(salt, ourPublicKey...)
232
salt = append(salt, r.theirPublicKey...)
233
h := hkdf.New(sha256.New, sharedSecret, salt, []byte(ed25519Label))
234
wrappingKey := make([]byte, chacha20poly1305.KeySize)
235
if _, err := io.ReadFull(h, wrappingKey); err != nil {
236
return nil, err
237
}
238
239
wrappedKey, err := aeadEncrypt(wrappingKey, fileKey)
240
if err != nil {
241
return nil, err
242
}
243
l.Body = wrappedKey
244
245
return []*age.Stanza{l}, nil
246 }
247
248 type Ed25519Identity struct {
249 secretKey, ourPublicKey []byte
250 sshKey ssh.PublicKey
251 }
252
253 var _ age.Identity = &Ed25519Identity{}
254
255
func NewEd25519Identity(key ed25519.PrivateKey) (*Ed25519Identity, error) {
256
s, err := ssh.NewSignerFromKey(key)
257
if err != nil {
258
return nil, err
259
}
260
i := &Ed25519Identity{
261
sshKey: s.PublicKey(),
262
secretKey: ed25519PrivateKeyToCurve25519(key),
263
}
264
i.ourPublicKey, _ = curve25519.X25519(i.secretKey, curve25519.Basepoint)
265
return i, nil
266 }
267
268
func ParseIdentity(pemBytes []byte) (age.Identity, error) {
269
k, err := ssh.ParseRawPrivateKey(pemBytes)
270
if err != nil {
271
return nil, err
272
}
273
274
switch k := k.(type) {
275
case *ed25519.PrivateKey:
276
return NewEd25519Identity(*k)
277 // ParseRawPrivateKey returns inconsistent types. See Issue 429.
278
case ed25519.PrivateKey:
279
return NewEd25519Identity(k)
280
case *rsa.PrivateKey:
281
return NewRSAIdentity(k)
282 }
283
284
return nil, fmt.Errorf("unsupported SSH identity type: %T", k)
285 }
286
287
func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte {
288
h := sha512.New()
289
h.Write(pk.Seed())
290
out := h.Sum(nil)
291
return out[:curve25519.ScalarSize]
292
}
293
294
func (i *Ed25519Identity) Recipient() *Ed25519Recipient {
295
return &Ed25519Recipient{
296
sshKey: i.sshKey,
297
theirPublicKey: i.ourPublicKey,
298
}
299
}
300
301
func (i *Ed25519Identity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
302
return multiUnwrap(i.unwrap, stanzas)
303
}
304
305
func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) {
306
if block.Type != "ssh-ed25519" {
307
return nil, age.ErrIncorrectIdentity
308
}
309
if len(block.Args) != 2 {
310
return nil, errors.New("invalid ssh-ed25519 recipient block")
311
}
312
publicKey, err := format.DecodeString(block.Args[1])
313
if err != nil {
314
return nil, fmt.Errorf("failed to parse ssh-ed25519 recipient: %v", err)
315
}
316
if len(publicKey) != curve25519.PointSize {
317
return nil, errors.New("invalid ssh-ed25519 recipient block")
318
}
319
320
if block.Args[0] != sshFingerprint(i.sshKey) {
321
return nil, age.ErrIncorrectIdentity
322
}
323
324
sharedSecret, err := curve25519.X25519(i.secretKey, publicKey)
325
if err != nil {
326
return nil, fmt.Errorf("invalid X25519 recipient: %v", err)
327
}
328
329
tweak := make([]byte, curve25519.ScalarSize)
330
tH := hkdf.New(sha256.New, nil, i.sshKey.Marshal(), []byte(ed25519Label))
331
if _, err := io.ReadFull(tH, tweak); err != nil {
332
return nil, err
333
}
334
sharedSecret, _ = curve25519.X25519(tweak, sharedSecret)
335
336
salt := make([]byte, 0, len(publicKey)+len(i.ourPublicKey))
337
salt = append(salt, publicKey...)
338
salt = append(salt, i.ourPublicKey...)
339
h := hkdf.New(sha256.New, sharedSecret, salt, []byte(ed25519Label))
340
wrappingKey := make([]byte, chacha20poly1305.KeySize)
341
if _, err := io.ReadFull(h, wrappingKey); err != nil {
342
return nil, err
343
}
344
345
fileKey, err := aeadDecrypt(wrappingKey, block.Body)
346
if err != nil {
347
return nil, fmt.Errorf("failed to decrypt file key: %v", err)
348
}
349
return fileKey, nil
350 }
351
352 // multiUnwrap is copied from package age. It's a helper that implements
353 // Identity.Unwrap in terms of a function that unwraps a single recipient
354 // stanza.
355
func multiUnwrap(unwrap func(*age.Stanza) ([]byte, error), stanzas []*age.Stanza) ([]byte, error) {
356
for _, s := range stanzas {
357
fileKey, err := unwrap(s)
358
if errors.Is(err, age.ErrIncorrectIdentity) {
359
// If we ever start returning something interesting wrapping
360
// ErrIncorrectIdentity, we should let it make its way up through
361
// Decrypt into NoIdentityMatchError.Errors.
362
continue
363 }
364
if err != nil {
365
return nil, err
366
}
367
return fileKey, nil
368 }
369
return nil, age.ErrIncorrectIdentity
370 }
371
372 // aeadEncrypt and aeadDecrypt are copied from package age.
373 //
374 // They don't limit the file key size because multi-key attacks are irrelevant
375 // against the ssh-ed25519 recipient. Being an asymmetric recipient, it would
376 // only allow a more efficient search for accepted public keys against a
377 // decryption oracle, but the ssh-X recipients are not anonymous (they have a
378 // short recipient hash).
379
380
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
381
aead, err := chacha20poly1305.New(key)
382
if err != nil {
383
return nil, err
384
}
385
nonce := make([]byte, chacha20poly1305.NonceSize)
386
return aead.Seal(nil, nonce, plaintext, nil), nil
387 }
388
389
func aeadDecrypt(key, ciphertext []byte) ([]byte, error) {
390
aead, err := chacha20poly1305.New(key)
391
if err != nil {
392
return nil, err
393
}
394
nonce := make([]byte, chacha20poly1305.NonceSize)
395
return aead.Open(nil, nonce, ciphertext, nil)
396 }
397
filippo.io/age/agessh/encrypted_keys.go 0.0%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package agessh
6
7 import (
8 "crypto"
9 "crypto/ed25519"
10 "crypto/rsa"
11 "fmt"
12
13 "filippo.io/age"
14 "golang.org/x/crypto/ssh"
15 )
16
17 // EncryptedSSHIdentity is an age.Identity implementation based on a passphrase
18 // encrypted SSH private key.
19 //
20 // It requests the passphrase only if the public key matches a recipient stanza.
21 // If the application knows it will always have to decrypt the private key, it
22 // would be simpler to use ssh.ParseRawPrivateKeyWithPassphrase directly and
23 // pass the result to NewEd25519Identity or NewRSAIdentity.
24 type EncryptedSSHIdentity struct {
25 pubKey ssh.PublicKey
26 recipient age.Recipient
27 pemBytes []byte
28 passphrase func() ([]byte, error)
29
30 decrypted age.Identity
31 }
32
33 // NewEncryptedSSHIdentity returns a new EncryptedSSHIdentity.
34 //
35 // pubKey must be the public key associated with the encrypted private key, and
36 // it must have type "ssh-ed25519" or "ssh-rsa". For OpenSSH encrypted files it
37 // can be extracted from an ssh.PassphraseMissingError, otherwise it can often
38 // be found in ".pub" files.
39 //
40 // pemBytes must be a valid input to ssh.ParseRawPrivateKeyWithPassphrase.
41 // passphrase is a callback that will be invoked by Unwrap when the passphrase
42 // is necessary.
43
func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, passphrase func() ([]byte, error)) (*EncryptedSSHIdentity, error) {
44
i := &EncryptedSSHIdentity{
45
pubKey: pubKey,
46
pemBytes: pemBytes,
47
passphrase: passphrase,
48
}
49
switch t := pubKey.Type(); t {
50
case "ssh-ed25519":
51
r, err := NewEd25519Recipient(pubKey)
52
if err != nil {
53
return nil, err
54
}
55
i.recipient = r
56
case "ssh-rsa":
57
r, err := NewRSARecipient(pubKey)
58
if err != nil {
59
return nil, err
60
}
61
i.recipient = r
62
default:
63
return nil, fmt.Errorf("unsupported SSH key type: %v", t)
64 }
65
return i, nil
66 }
67
68 var _ age.Identity = &EncryptedSSHIdentity{}
69
70
func (i *EncryptedSSHIdentity) Recipient() age.Recipient {
71
return i.recipient
72
}
73
74 // Unwrap implements age.Identity. If the private key is still encrypted, and
75 // any of the stanzas match the public key, it will request the passphrase. The
76 // decrypted private key will be cached after the first successful invocation.
77
func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
78
if i.decrypted != nil {
79
return i.decrypted.Unwrap(stanzas)
80
}
81
82
var match bool
83
for _, s := range stanzas {
84
if s.Type != i.pubKey.Type() {
85
continue
86 }
87
if len(s.Args) < 1 {
88
return nil, fmt.Errorf("invalid %v recipient block", i.pubKey.Type())
89
}
90
if s.Args[0] != sshFingerprint(i.pubKey) {
91
continue
92 }
93
match = true
94
break
95 }
96
if !match {
97
return nil, age.ErrIncorrectIdentity
98
}
99
100
passphrase, err := i.passphrase()
101
if err != nil {
102
return nil, fmt.Errorf("failed to obtain passphrase: %v", err)
103
}
104
k, err := ssh.ParseRawPrivateKeyWithPassphrase(i.pemBytes, passphrase)
105
if err != nil {
106
return nil, fmt.Errorf("failed to decrypt SSH key file: %v", err)
107
}
108
109
var pubKey interface {
110
Equal(x crypto.PublicKey) bool
111
}
112
switch k := k.(type) {
113
case *ed25519.PrivateKey:
114
i.decrypted, err = NewEd25519Identity(*k)
115
pubKey = k.Public().(ed25519.PublicKey)
116 // ParseRawPrivateKey returns inconsistent types. See Issue 429.
117
case ed25519.PrivateKey:
118
i.decrypted, err = NewEd25519Identity(k)
119
pubKey = k.Public().(ed25519.PublicKey)
120
case *rsa.PrivateKey:
121
i.decrypted, err = NewRSAIdentity(k)
122
pubKey = &k.PublicKey
123
default:
124
return nil, fmt.Errorf("unexpected SSH key type: %T", k)
125 }
126
if err != nil {
127
return nil, fmt.Errorf("invalid SSH key: %v", err)
128
}
129
130
if exp := i.pubKey.(ssh.CryptoPublicKey).CryptoPublicKey(); !pubKey.Equal(exp) {
131
return nil, fmt.Errorf("mismatched private and public SSH key")
132
}
133
134
return i.decrypted.Unwrap(stanzas)
135 }
136
filippo.io/age/armor/armor.go 72.9%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 // Package armor provides a strict, streaming implementation of the ASCII
6 // armoring format for age files.
7 //
8 // It's PEM with type "AGE ENCRYPTED FILE", 64 character columns, no headers,
9 // and strict base64 decoding.
10 package armor
11
12 import (
13 "bufio"
14 "bytes"
15 "encoding/base64"
16 "errors"
17 "fmt"
18 "io"
19
20 "filippo.io/age/internal/format"
21 )
22
23 const (
24 Header = "-----BEGIN AGE ENCRYPTED FILE-----"
25 Footer = "-----END AGE ENCRYPTED FILE-----"
26 )
27
28 type armoredWriter struct {
29 started, closed bool
30 encoder *format.WrappedBase64Encoder
31 dst io.Writer
32 }
33
34
func (a *armoredWriter) Write(p []byte) (int, error) {
35
if !a.started {
36
if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
37
return 0, err
38
}
39 }
40
a.started = true
41
return a.encoder.Write(p)
42 }
43
44
func (a *armoredWriter) Close() error {
45
if a.closed {
46
return errors.New("ArmoredWriter already closed")
47
}
48
a.closed = true
49
if err := a.encoder.Close(); err != nil {
50
return err
51
}
52
footer := Footer + "\n"
53
if !a.encoder.LastLineIsEmpty() {
54
footer = "\n" + footer
55
}
56
_, err := io.WriteString(a.dst, footer)
57
return err
58 }
59
60
func NewWriter(dst io.Writer) io.WriteCloser {
61
// TODO: write a test with aligned and misaligned sizes, and 8 and 10 steps.
62
return &armoredWriter{
63
dst: dst,
64
encoder: format.NewWrappedBase64Encoder(base64.StdEncoding, dst),
65
}
66
}
67
68 type armoredReader struct {
69 r *bufio.Reader
70 started bool
71 unread []byte // backed by buf
72 buf [format.BytesPerLine]byte
73 err error
74 }
75
76
func NewReader(r io.Reader) io.Reader {
77
return &armoredReader{r: bufio.NewReader(r)}
78
}
79
80
func (r *armoredReader) Read(p []byte) (int, error) {
81
if len(r.unread) > 0 {
82
n := copy(p, r.unread)
83
r.unread = r.unread[n:]
84
return n, nil
85
}
86
if r.err != nil {
87
return 0, r.err
88
}
89
90
getLine := func() ([]byte, error) {
91
line, err := r.r.ReadBytes('\n')
92
if err == io.EOF && len(line) == 0 {
93
return nil, io.ErrUnexpectedEOF
94
} else if err != nil && err != io.EOF {
95
return nil, err
96
}
97
line = bytes.TrimSuffix(line, []byte("\n"))
98
line = bytes.TrimSuffix(line, []byte("\r"))
99
return line, nil
100 }
101
102
const maxWhitespace = 1024
103
drainTrailing := func() error {
104
buf, err := io.ReadAll(io.LimitReader(r.r, maxWhitespace))
105
if err != nil {
106
return err
107
}
108
if len(bytes.TrimSpace(buf)) != 0 {
109
return errors.New("trailing data after armored file")
110
}
111
if len(buf) == maxWhitespace {
112
return errors.New("too much trailing whitespace")
113
}
114
return io.EOF
115 }
116
117
var removedWhitespace int
118
for !r.started {
119
line, err := getLine()
120
if err != nil {
121
return 0, r.setErr(err)
122
}
123 // Ignore leading whitespace.
124
if len(bytes.TrimSpace(line)) == 0 {
125
removedWhitespace += len(line) + 1
126
if removedWhitespace > maxWhitespace {
127
return 0, r.setErr(errors.New("too much leading whitespace"))
128
}
129
continue
130 }
131
if string(line) != Header {
132
return 0, r.setErr(fmt.Errorf("invalid first line: %q", line))
133
}
134
r.started = true
135 }
136
line, err := getLine()
137
if err != nil {
138
return 0, r.setErr(err)
139
}
140
if string(line) == Footer {
141
return 0, r.setErr(drainTrailing())
142
}
143
if len(line) == 0 {
144
return 0, r.setErr(errors.New("empty line in armored data"))
145
}
146
if len(line) > format.ColumnsPerLine {
147
return 0, r.setErr(errors.New("column limit exceeded"))
148
}
149
r.unread = r.buf[:]
150
n, err := base64.StdEncoding.Strict().Decode(r.unread, line)
151
if err != nil {
152
return 0, r.setErr(err)
153
}
154
r.unread = r.unread[:n]
155
156
if n < format.BytesPerLine {
157
line, err := getLine()
158
if err != nil {
159
return 0, r.setErr(err)
160
}
161
if string(line) != Footer {
162
return 0, r.setErr(fmt.Errorf("invalid closing line: %q", line))
163
}
164
r.setErr(drainTrailing())
165 }
166
167
nn := copy(p, r.unread)
168
r.unread = r.unread[nn:]
169
return nn, nil
170 }
171
172 type Error struct {
173 err error
174 }
175
176
func (e *Error) Error() string {
177
return "invalid armor: " + e.err.Error()
178
}
179
180
func (e *Error) Unwrap() error {
181
return e.err
182
}
183
184
func (r *armoredReader) setErr(err error) error {
185
if err != io.EOF {
186
err = &Error{err}
187
}
188
r.err = err
189
return err
190 }
191
filippo.io/age/cmd/age-inspect/inspect.go 0.0%
1 // Copyright 2025 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package main
6
7 import (
8 "encoding/json"
9 "flag"
10 "fmt"
11 "log"
12 "os"
13 "runtime/debug"
14
15 "filippo.io/age/internal/inspect"
16 )
17
18 const usage = `Usage:
19 age-inspect [--json] [INPUT]
20
21 Options:
22 --json Output machine-readable JSON.
23
24 INPUT defaults to standard input. "-" may be used as INPUT to explicitly
25 read from standard input.`
26
27 // Version can be set at link time to override debug.BuildInfo.Main.Version when
28 // building manually without git history. It should look like "v1.2.3".
29 var Version string
30
31
func main() {
32
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
33
34
var (
35
versionFlag bool
36
jsonFlag bool
37
)
38
39
flag.BoolVar(&versionFlag, "version", false, "print the version")
40
flag.BoolVar(&jsonFlag, "json", false, "output machine-readable JSON")
41
flag.Parse()
42
43
if versionFlag {
44
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
45
Version = buildInfo.Main.Version
46
}
47
fmt.Println(Version)
48
return
49 }
50
51
if flag.NArg() > 1 {
52
flag.Usage()
53
os.Exit(1)
54
}
55
56
in := os.Stdin
57
var fileSize int64 = -1
58
if name := flag.Arg(0); name != "" && name != "-" {
59
f, err := os.Open(name)
60
if err != nil {
61
errorf("failed to open input file %q: %v", name, err)
62
}
63
defer f.Close()
64
in = f
65
if stat, err := f.Stat(); err == nil && stat.Mode().IsRegular() {
66
fileSize = stat.Size()
67
}
68 }
69
70
data, err := inspect.Inspect(in, fileSize)
71
if err != nil {
72
errorf("inspection failed: %v", err)
73
}
74
75
if jsonFlag {
76
enc := json.NewEncoder(os.Stdout)
77
enc.SetIndent("", " ")
78
if err := enc.Encode(data); err != nil {
79
errorf("failed to encode JSON output: %v", err)
80
}
81
} else {
82
name := flag.Arg(0)
83
if name == "" {
84
name = "<stdin>"
85
}
86
fmt.Printf("%s is an age file, version %q.\n", name, data.Version)
87
fmt.Printf("\n")
88
if data.Armor {
89
fmt.Printf("This file is ASCII-armored.\n")
90
fmt.Printf("\n")
91
}
92
fmt.Printf("This file is encrypted to the following recipient types:\n")
93
for _, t := range data.StanzaTypes {
94
fmt.Printf(" - %q\n", t)
95
}
96
fmt.Printf("\n")
97
switch data.Postquantum {
98
case "yes":
99
fmt.Printf("This file uses post-quantum encryption.\n")
100
fmt.Printf("\n")
101
case "no":
102
fmt.Printf("This file does NOT use post-quantum encryption.\n")
103
fmt.Printf("\n")
104 }
105
fmt.Printf("Size breakdown (assuming it decrypts successfully):\n")
106
fmt.Printf("\n")
107
fmt.Printf(" Header % 12d bytes\n", data.Sizes.Header)
108
if data.Armor {
109
fmt.Printf(" Armor overhead % 12d bytes\n", data.Sizes.Armor)
110
}
111
fmt.Printf(" Encryption overhead % 12d bytes\n", data.Sizes.Overhead)
112
fmt.Printf(" Payload % 12d bytes\n", data.Sizes.MinPayload)
113
fmt.Printf(" -------------------\n")
114
total := data.Sizes.Header + data.Sizes.Overhead + data.Sizes.MinPayload + data.Sizes.Armor
115
fmt.Printf(" Total % 12d bytes\n", total)
116
fmt.Printf("\n")
117
fmt.Printf("Tip: for machine-readable output, use --json.\n")
118 }
119 }
120
121 // l is a logger with no prefixes.
122 var l = log.New(os.Stderr, "", 0)
123
124
func errorf(format string, v ...any) {
125
l.Printf("age-inspect: error: "+format, v...)
126
l.Printf("age-inspect: report unexpected or unhelpful errors at https://filippo.io/age/report")
127
os.Exit(1)
128
}
129
filippo.io/age/cmd/age-keygen/keygen.go 0.0%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package main
6
7 import (
8 "flag"
9 "fmt"
10 "io"
11 "log"
12 "os"
13 "runtime/debug"
14 "time"
15
16 "filippo.io/age"
17 "golang.org/x/term"
18 )
19
20 const usage = `Usage:
21 age-keygen [-pq] [-o OUTPUT]
22 age-keygen -y [-o OUTPUT] [INPUT]
23
24 Options:
25 -pq Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
26 (This might become the default in the future.)
27 -o, --output OUTPUT Write the result to the file at path OUTPUT.
28 -y Convert an identity file to a recipients file.
29
30 age-keygen generates a new native X25519 or, with the -pq flag, post-quantum
31 hybrid ML-KEM-768 + X25519 key pair, and outputs it to standard output or to
32 the OUTPUT file.
33
34 If an OUTPUT file is specified, the public key is printed to standard error.
35 If OUTPUT already exists, it is not overwritten.
36
37 In -y mode, age-keygen reads an identity file from INPUT or from standard
38 input and writes the corresponding recipient(s) to OUTPUT or to standard
39 output, one per line, with no comments.
40
41 Examples:
42
43 $ age-keygen
44 # created: 2021-01-02T15:30:45+01:00
45 # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
46 AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
47
48 $ age-keygen -pq
49 # created: 2025-11-17T12:15:17+01:00
50 # public key: age1pq1pd[... 1950 more characters ...]
51 AGE-SECRET-KEY-PQ-1XXC4XS9DXHZ6TREKQTT3XECY8VNNU7GJ83C3Y49D0GZ3ZUME4JWS6QC3EF
52
53 $ age-keygen -o key.txt
54 Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
55
56 $ age-keygen -y key.txt
57 age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p`
58
59 // Version can be set at link time to override debug.BuildInfo.Main.Version when
60 // building manually without git history. It should look like "v1.2.3".
61 var Version string
62
63
func main() {
64
log.SetFlags(0)
65
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
66
67
var outFlag string
68
var pqFlag, versionFlag, convertFlag bool
69
70
flag.BoolVar(&versionFlag, "version", false, "print the version")
71
flag.BoolVar(&pqFlag, "pq", false, "generate a post-quantum key pair")
72
flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients")
73
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
74
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
75
flag.Parse()
76
77
if versionFlag {
78
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
79
Version = buildInfo.Main.Version
80
}
81
fmt.Println(Version)
82
return
83 }
84
85
if len(flag.Args()) != 0 && !convertFlag {
86
errorf("too many arguments")
87
}
88
if len(flag.Args()) > 1 && convertFlag {
89
errorf("too many arguments")
90
}
91
if pqFlag && convertFlag {
92
errorf("-pq cannot be used with -y")
93
}
94
95
out := os.Stdout
96
if outFlag != "" {
97
f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
98
if err != nil {
99
errorf("failed to open output file %q: %v", outFlag, err)
100
}
101
defer func() {
102
if err := f.Close(); err != nil {
103
errorf("failed to close output file %q: %v", outFlag, err)
104
}
105 }()
106
out = f
107 }
108
109
in := os.Stdin
110
if inFile := flag.Arg(0); inFile != "" && inFile != "-" {
111
f, err := os.Open(inFile)
112
if err != nil {
113
errorf("failed to open input file %q: %v", inFile, err)
114
}
115
defer f.Close()
116
in = f
117 }
118
119
if convertFlag {
120
convert(in, out)
121
} else {
122
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
123
warning("writing secret key to a world-readable file")
124
}
125
generate(out, pqFlag)
126 }
127 }
128
129
func generate(out *os.File, pq bool) {
130
var i age.Identity
131
var r age.Recipient
132
if pq {
133
k, err := age.GenerateHybridIdentity()
134
if err != nil {
135
errorf("internal error: %v", err)
136
}
137
i = k
138
r = k.Recipient()
139
} else {
140
k, err := age.GenerateX25519Identity()
141
if err != nil {
142
errorf("internal error: %v", err)
143
}
144
i = k
145
r = k.Recipient()
146 }
147
148
if !term.IsTerminal(int(out.Fd())) {
149
fmt.Fprintf(os.Stderr, "Public key: %s\n", r)
150
}
151
152
fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339))
153
fmt.Fprintf(out, "# public key: %s\n", r)
154
fmt.Fprintf(out, "%s\n", i)
155 }
156
157
func convert(in io.Reader, out io.Writer) {
158
ids, err := age.ParseIdentities(in)
159
if err != nil {
160
errorf("failed to parse input: %v", err)
161
}
162
if len(ids) == 0 {
163
errorf("no identities found in the input")
164
}
165
for _, id := range ids {
166
switch id := id.(type) {
167
case *age.X25519Identity:
168
fmt.Fprintf(out, "%s\n", id.Recipient())
169
case *age.HybridIdentity:
170
fmt.Fprintf(out, "%s\n", id.Recipient())
171
default:
172
errorf("internal error: unexpected identity type: %T", id)
173 }
174
175 }
176 }
177
178
func errorf(format string, v ...any) {
179
log.Printf("age-keygen: error: "+format, v...)
180
log.Fatalf("age-keygen: report unexpected or unhelpful errors at https://filippo.io/age/report")
181
}
182
183
func warning(msg string) {
184
log.Printf("age-keygen: warning: %s", msg)
185
}
186
filippo.io/age/cmd/age-plugin-batchpass/plugin-batchpass.go 0.0%
1 package main
2
3 import (
4 "errors"
5 "flag"
6 "fmt"
7 "io"
8 "log"
9 "os"
10 "runtime/debug"
11 "strconv"
12 "strings"
13
14 "filippo.io/age"
15 "filippo.io/age/plugin"
16 )
17
18 const usage = `age-plugin-batchpass is an age plugin that enables non-interactive
19 passphrase-based encryption and decryption using environment variables.
20
21 WARNING: IN 90% OF CASES, YOU DON'T NEED THIS PLUGIN.
22
23 This functionality is not built into the age CLI because most applications
24 should use native keys instead of scripting passphrase-based encryption.
25
26 Humans are notoriously bad at remembering and generating strong passphrases.
27 age uses scrypt to partially mitigate this, which is necessarily very slow.
28
29 If a computer will be doing the remembering anyway, you can and should use
30 native keys instead. There is no need to manage separate public and private
31 keys, you encrypt directly to the private key:
32
33 $ age-keygen -o key.txt
34 $ age -e -i key.txt file.txt > file.txt.age
35 $ age -d -i key.txt file.txt.age > file.txt
36
37 Likewise, you can store a native identity string in an environment variable
38 or through your CI secrets manager and use it to encrypt and decrypt files
39 non-interactively:
40
41 $ export AGE_SECRET=$(age-keygen)
42 $ age -e -i <(echo "$AGE_SECRET") file.txt > file.txt.age
43 $ age -d -i <(echo "$AGE_SECRET") file.txt.age > file.txt
44
45 The age CLI also natively supports passphrase-encrypted identity files, so you
46 can use that functionality to non-interactively encrypt multiple files such that
47 you will be able to decrypt them later by entering the same passphrase:
48
49 $ age-keygen -pq | age -p -o encrypted-identity.txt
50 Public key: age1pq1cd[... 1950 more characters ...]
51 Enter passphrase (leave empty to autogenerate a secure one):
52 age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
53 $ age -r age1pq1cd[... 1950 more characters ...] file.txt > file.txt.age
54 $ age -d -i encrypted-identity.txt file.txt.age > file.txt
55 Enter passphrase for identity file "encrypted-identity.txt":
56
57 Finally, when using this plugin care should be taken not to let the password be
58 persisted in the shell history or leaked to other users on multi-user systems.
59
60 Usage:
61
62 $ AGE_PASSPHRASE=password age -e -j batchpass file.txt > file.txt.age
63
64 $ AGE_PASSPHRASE=password age -d -j batchpass file.txt.age > file.txt
65
66 Alternatively, you can use AGE_PASSPHRASE_FD to read the passphrase from
67 a file descriptor. Trailing newlines are stripped from the file contents.
68
69 When encrypting, you can set AGE_PASSPHRASE_WORK_FACTOR to adjust the scrypt
70 work factor (between 1 and 30, default 18). Higher values are more secure
71 but slower.
72
73 When decrypting, you can set AGE_PASSPHRASE_MAX_WORK_FACTOR to limit the
74 maximum scrypt work factor accepted (between 1 and 30, default 30). This can
75 be used to avoid very slow decryptions.`
76
77 // Version can be set at link time to override debug.BuildInfo.Main.Version when
78 // building manually without git history. It should look like "v1.2.3".
79 var Version string
80
81
func main() {
82
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
83
84
p, err := plugin.New("batchpass")
85
if err != nil {
86
log.Fatal(err)
87
}
88
p.RegisterFlags(nil)
89
90
versionFlag := flag.Bool("version", false, "print the version")
91
flag.Parse()
92
93
if *versionFlag {
94
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
95
Version = buildInfo.Main.Version
96
}
97
fmt.Println(Version)
98
return
99 }
100
101
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
102
if len(data) != 0 {
103
return nil, fmt.Errorf("batchpass identity does not take any payload")
104
}
105
pass, err := passphrase()
106
if err != nil {
107
return nil, err
108
}
109
r, err := age.NewScryptRecipient(pass)
110
if err != nil {
111
return nil, fmt.Errorf("failed to create scrypt recipient: %v", err)
112
}
113
if envWorkFactor := os.Getenv("AGE_PASSPHRASE_WORK_FACTOR"); envWorkFactor != "" {
114
workFactor, err := strconv.Atoi(envWorkFactor)
115
if err != nil {
116
return nil, fmt.Errorf("invalid AGE_PASSPHRASE_WORK_FACTOR: %v", err)
117
}
118
if workFactor > 30 || workFactor < 1 {
119
return nil, fmt.Errorf("AGE_PASSPHRASE_WORK_FACTOR must be between 1 and 30")
120
}
121
r.SetWorkFactor(workFactor)
122 }
123
return r, nil
124 })
125
p.HandleIdentity(func(data []byte) (age.Identity, error) {
126
if len(data) != 0 {
127
return nil, fmt.Errorf("batchpass identity does not take any payload")
128
}
129
pass, err := passphrase()
130
if err != nil {
131
return nil, err
132
}
133
maxWorkFactor := 0
134
if envMaxWorkFactor := os.Getenv("AGE_PASSPHRASE_MAX_WORK_FACTOR"); envMaxWorkFactor != "" {
135
maxWorkFactor, err = strconv.Atoi(envMaxWorkFactor)
136
if err != nil {
137
return nil, fmt.Errorf("invalid AGE_PASSPHRASE_MAX_WORK_FACTOR: %v", err)
138
}
139
if maxWorkFactor > 30 || maxWorkFactor < 1 {
140
return nil, fmt.Errorf("AGE_PASSPHRASE_MAX_WORK_FACTOR must be between 1 and 30")
141
}
142 }
143
return &batchpassIdentity{password: pass, maxWorkFactor: maxWorkFactor}, nil
144 })
145
os.Exit(p.Main())
146 }
147
148 type batchpassIdentity struct {
149 password string
150 maxWorkFactor int
151 }
152
153
func (i *batchpassIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
154
for _, s := range stanzas {
155
if s.Type == "scrypt" && len(stanzas) != 1 {
156
return nil, errors.New("an scrypt recipient must be the only one")
157
}
158 }
159
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
160
// Don't fallback to other identities, this plugin should mostly be used
161
// in isolation, from the CLI.
162
return nil, fmt.Errorf("file is not passphrase-encrypted")
163
}
164
ii, err := age.NewScryptIdentity(i.password)
165
if err != nil {
166
return nil, err
167
}
168
if i.maxWorkFactor != 0 {
169
ii.SetMaxWorkFactor(i.maxWorkFactor)
170
}
171
fileKey, err := ii.Unwrap(stanzas)
172
if errors.Is(err, age.ErrIncorrectIdentity) {
173
// ScryptIdentity returns ErrIncorrectIdentity to make it possible to
174
// try multiple passphrases from the API. If a user is invoking this
175
// plugin, it's safe to say they expect it to be the only mechanism to
176
// decrypt a passphrase-protected file.
177
return nil, fmt.Errorf("incorrect passphrase")
178
}
179
return fileKey, err
180 }
181
182
func passphrase() (string, error) {
183
envPASSPHRASE := os.Getenv("AGE_PASSPHRASE")
184
envFD := os.Getenv("AGE_PASSPHRASE_FD")
185
if envPASSPHRASE != "" && envFD != "" {
186
return "", fmt.Errorf("AGE_PASSPHRASE and AGE_PASSPHRASE_FD are mutually exclusive")
187
}
188
if envPASSPHRASE == "" && envFD == "" {
189
return "", fmt.Errorf("either AGE_PASSPHRASE or AGE_PASSPHRASE_FD must be set")
190
}
191
192
if envPASSPHRASE != "" {
193
return envPASSPHRASE, nil
194
}
195
196
fd, err := strconv.Atoi(envFD)
197
if err != nil {
198
return "", fmt.Errorf("invalid AGE_PASSPHRASE_FD: %v", err)
199
}
200
f := os.NewFile(uintptr(fd), "AGE_PASSPHRASE_FD")
201
if f == nil {
202
return "", fmt.Errorf("failed to open file descriptor %d", fd)
203
}
204
defer f.Close()
205
const maxPassphraseSize = 1024 * 1024 // 1 MiB
206
b, err := io.ReadAll(io.LimitReader(f, maxPassphraseSize+1))
207
if err != nil {
208
return "", fmt.Errorf("failed to read passphrase from fd %d: %v", fd, err)
209
}
210
if len(b) > maxPassphraseSize {
211
return "", fmt.Errorf("passphrase from fd %d is too long", fd)
212
}
213
return strings.TrimRight(string(b), "\r\n"), nil
214 }
215
filippo.io/age/cmd/age/age.go 62.5%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package main
6
7 import (
8 "bufio"
9 "bytes"
10 "errors"
11 "flag"
12 "fmt"
13 "io"
14 "iter"
15 "os"
16 "path/filepath"
17 "regexp"
18 "runtime/debug"
19 "slices"
20 "strings"
21 "unicode"
22
23 "filippo.io/age"
24 "filippo.io/age/agessh"
25 "filippo.io/age/armor"
26 "filippo.io/age/internal/term"
27 "filippo.io/age/plugin"
28 )
29
30 const usage = `Usage:
31 age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
32 age [--encrypt] --passphrase [--armor] [-o OUTPUT] [INPUT]
33 age --decrypt [-i PATH]... [-o OUTPUT] [INPUT]
34
35 Options:
36 -e, --encrypt Encrypt the input to the output. Default if omitted.
37 -d, --decrypt Decrypt the input to the output.
38 -o, --output OUTPUT Write the result to the file at path OUTPUT.
39 -a, --armor Encrypt to a PEM encoded format.
40 -p, --passphrase Encrypt with a passphrase.
41 -r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
42 -R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
43 -i, --identity PATH Use the identity file at PATH. Can be repeated.
44
45 INPUT defaults to standard input, and OUTPUT defaults to standard output.
46 If OUTPUT exists, it will be overwritten.
47
48 RECIPIENT can be an age public key generated by age-keygen ("age1...")
49 or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA...").
50
51 Recipient files contain one or more recipients, one per line. Empty lines
52 and lines starting with "#" are ignored as comments. "-" may be used to
53 read recipients from standard input.
54
55 Identity files contain one or more secret keys ("AGE-SECRET-KEY-1..."),
56 one per line, or an SSH key. Empty lines and lines starting with "#" are
57 ignored as comments. Passphrase encrypted age files can be used as
58 identity files. Multiple key files can be provided, and any unused ones
59 will be ignored. "-" may be used to read identities from standard input.
60
61 When --encrypt is specified explicitly, -i can also be used to encrypt to an
62 identity file symmetrically, instead or in addition to normal recipients.
63
64 Example:
65 $ age-keygen -o key.txt
66 Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
67 $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
68 $ age --decrypt -i key.txt -o data.tar.gz data.tar.gz.age`
69
70 // stdinInUse is used to ensure only one of input, recipients, or identities
71 // file is read from stdin. It's a singleton like os.Stdin.
72 var stdinInUse bool
73
74 type multiFlag []string
75
76
func (f *multiFlag) String() string { return fmt.Sprint(*f) }
77
78
func (f *multiFlag) Set(value string) error {
79
*f = append(*f, value)
80
return nil
81
}
82
83 type identityFlag struct {
84 Type, Value string
85 }
86
87 // identityFlags tracks -i and -j flags, preserving their relative order, so
88 // that "age -d -j agent -i encrypted-fallback-keys.age" behaves as expected.
89 type identityFlags []identityFlag
90
91
func (f *identityFlags) addIdentityFlag(value string) error {
92
*f = append(*f, identityFlag{Type: "i", Value: value})
93
return nil
94
}
95
96
func (f *identityFlags) addPluginFlag(value string) error {
97
*f = append(*f, identityFlag{Type: "j", Value: value})
98
return nil
99
}
100
101 // Version can be set at link time to override debug.BuildInfo.Main.Version when
102 // building manually without git history. It should look like "v1.2.3".
103 var Version string
104
105
func main() {
106
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
107
108
if len(os.Args) == 1 {
109
flag.Usage()
110
os.Exit(1)
111
}
112
113
var (
114
outFlag string
115
decryptFlag, encryptFlag bool
116
passFlag, versionFlag, armorFlag bool
117
recipientFlags multiFlag
118
recipientsFileFlags multiFlag
119
identityFlags identityFlags
120
)
121
122
flag.BoolVar(&versionFlag, "version", false, "print the version")
123
flag.BoolVar(&decryptFlag, "d", false, "decrypt the input")
124
flag.BoolVar(&decryptFlag, "decrypt", false, "decrypt the input")
125
flag.BoolVar(&encryptFlag, "e", false, "encrypt the input")
126
flag.BoolVar(&encryptFlag, "encrypt", false, "encrypt the input")
127
flag.BoolVar(&passFlag, "p", false, "use a passphrase")
128
flag.BoolVar(&passFlag, "passphrase", false, "use a passphrase")
129
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
130
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
131
flag.BoolVar(&armorFlag, "a", false, "generate an armored file")
132
flag.BoolVar(&armorFlag, "armor", false, "generate an armored file")
133
flag.Var(&recipientFlags, "r", "recipient (can be repeated)")
134
flag.Var(&recipientFlags, "recipient", "recipient (can be repeated)")
135
flag.Var(&recipientsFileFlags, "R", "recipients file (can be repeated)")
136
flag.Var(&recipientsFileFlags, "recipients-file", "recipients file (can be repeated)")
137
flag.Func("i", "identity (can be repeated)", identityFlags.addIdentityFlag)
138
flag.Func("identity", "identity (can be repeated)", identityFlags.addIdentityFlag)
139
flag.Func("j", "data-less plugin (can be repeated)", identityFlags.addPluginFlag)
140
flag.Parse()
141
142
if versionFlag {
143
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
144
Version = buildInfo.Main.Version
145
}
146
fmt.Println(Version)
147
return
148 }
149
150
if flag.NArg() > 1 {
151
var hints []string
152
quotedArgs := strings.Trim(fmt.Sprintf("%q", flag.Args()), "[]")
153
154
// If the second argument looks like a flag, suggest moving the first
155
// argument to the back (as long as the arguments don't need quoting).
156
if strings.HasPrefix(flag.Arg(1), "-") {
157
hints = append(hints, "the input file must be specified after all flags")
158
159
safe := true
160
unsafeShell := regexp.MustCompile(`[^\w@%+=:,./-]`)
161
if slices.ContainsFunc(os.Args, unsafeShell.MatchString) {
162
safe = false
163
}
164
if safe {
165
i := len(os.Args) - flag.NArg()
166
newArgs := append([]string{}, os.Args[:i]...)
167
newArgs = append(newArgs, os.Args[i+1:]...)
168
newArgs = append(newArgs, os.Args[i])
169
hints = append(hints, "did you mean:")
170
hints = append(hints, " "+strings.Join(newArgs, " "))
171
}
172
} else {
173
hints = append(hints, "only a single input file may be specified at a time")
174
}
175
176
errorWithHint("too many INPUT arguments: "+quotedArgs, hints...)
177 }
178
179
switch {
180
case decryptFlag:
181
if encryptFlag {
182
errorf("-e/--encrypt can't be used with -d/--decrypt")
183
}
184
if armorFlag {
185
errorWithHint("-a/--armor can't be used with -d/--decrypt",
186
"note that armored files are detected automatically, try again without -a/--armor")
187
}
188
if passFlag {
189
errorWithHint("-p/--passphrase can't be used with -d/--decrypt",
190
"note that password protected files are detected automatically")
191
}
192
if len(recipientFlags) > 0 {
193
errorWithHint("-r/--recipient can't be used with -d/--decrypt",
194
"did you mean to use -i/--identity to specify a private key?")
195
}
196
if len(recipientsFileFlags) > 0 {
197
errorWithHint("-R/--recipients-file can't be used with -d/--decrypt",
198
"did you mean to use -i/--identity to specify a private key?")
199
}
200
default: // encrypt
201
if len(identityFlags) > 0 && !encryptFlag {
202
errorWithHint("-i/--identity and -j can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt",
203
"did you forget to specify -d/--decrypt?")
204
}
205
if len(recipientFlags)+len(recipientsFileFlags)+len(identityFlags) == 0 && !passFlag {
206
errorWithHint("missing recipients",
207
"did you forget to specify -r/--recipient, -R/--recipients-file or -p/--passphrase?")
208
}
209
if len(recipientFlags) > 0 && passFlag {
210
errorf("-p/--passphrase can't be combined with -r/--recipient")
211
}
212
if len(recipientsFileFlags) > 0 && passFlag {
213
errorf("-p/--passphrase can't be combined with -R/--recipients-file")
214
}
215
if len(identityFlags) > 0 && passFlag {
216
errorf("-p/--passphrase can't be combined with -i/--identity and -j")
217
}
218 }
219
220
warnDuplicates(slices.Values(recipientFlags), "recipient")
221
warnDuplicates(slices.Values(recipientsFileFlags), "recipients file")
222
warnDuplicates(func(yield func(string) bool) {
223
for _, f := range identityFlags {
224
if f.Type == "i" && !yield(f.Value) {
225
return
226
}
227 }
228 }, "identity file")
229
230
var inUseFiles []string
231
for _, i := range identityFlags {
232
if i.Type != "i" {
233
continue
234 }
235
inUseFiles = append(inUseFiles, absPath(i.Value))
236 }
237
for _, f := range recipientsFileFlags {
238
inUseFiles = append(inUseFiles, absPath(f))
239
}
240
241
var in io.Reader = os.Stdin
242
var out io.Writer = os.Stdout
243
if name := flag.Arg(0); name != "" && name != "-" {
244
inUseFiles = append(inUseFiles, absPath(name))
245
f, err := os.Open(name)
246
if err != nil {
247
errorf("failed to open input file %q: %v", name, err)
248
}
249
defer f.Close()
250
in = f
251
} else {
252
stdinInUse = true
253
if decryptFlag && term.IsTerminal(os.Stdin) {
254
// If the input comes from a TTY, assume it's armored, and buffer up
255
// to the END line (or EOF/EOT) so that a password prompt or the
256
// output don't get in the way of typing the input. See Issue 364.
257
buf, err := bufferTerminalInput(in)
258
if err != nil {
259
errorf("failed to buffer terminal input: %v", err)
260
}
261
in = buf
262 }
263 }
264
if name := outFlag; name != "" && name != "-" {
265
for _, f := range inUseFiles {
266
if f == absPath(name) {
267
errorf("input and output file are the same: %q", name)
268
}
269 }
270
f := newLazyOpener(name)
271
defer func() {
272
if err := f.Close(); err != nil {
273
errorf("failed to close output file %q: %v", name, err)
274
}
275 }()
276
out = f
277
} else if term.IsTerminal(os.Stdout) {
278
buf := &bytes.Buffer{}
279
defer func() {
280
if out == buf {
281
io.Copy(os.Stdout, buf)
282
}
283 }()
284
if name != "-" {
285
if decryptFlag {
286
// Buffer the output to check it's printable.
287
out = buf
288
defer func() {
289
if bytes.ContainsFunc(buf.Bytes(), func(r rune) bool {
290
return r != '\n' && r != '\r' && r != '\t' && unicode.IsControl(r)
291
}) {
292
errorWithHint("refusing to output binary to the terminal",
293
`force anyway with "-o -"`)
294
}
295 }()
296
} else if !armorFlag {
297
// If the output wouldn't be armored, refuse to send binary to
298
// the terminal unless explicitly requested with "-o -".
299
errorWithHint("refusing to output binary to the terminal",
300
"did you mean to use -a/--armor?",
301
`force anyway with "-o -"`)
302
}
303 }
304
if in == os.Stdin && term.IsTerminal(os.Stdin) {
305
// If the input comes from a TTY and output will go to a TTY,
306
// buffer it up so it doesn't get in the way of typing the input.
307
out = buf
308
}
309 }
310
311
switch {
312
case decryptFlag && len(identityFlags) == 0:
313
decryptPass(in, out)
314
case decryptFlag:
315
decryptNotPass(identityFlags, in, out)
316
case passFlag:
317
encryptPass(in, out, armorFlag)
318
default:
319
encryptNotPass(recipientFlags, recipientsFileFlags, identityFlags, in, out, armorFlag)
320 }
321 }
322
323
func passphrasePromptForEncryption() (string, error) {
324
pass, err := term.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):")
325
if err != nil {
326
return "", fmt.Errorf("could not read passphrase: %v", err)
327
}
328
p := string(pass)
329
if p == "" {
330
var words []string
331
for range 10 {
332
words = append(words, randomWord())
333
}
334
p = strings.Join(words, "-")
335
err := printfToTerminal("using autogenerated passphrase %q", p)
336
if err != nil {
337
return "", fmt.Errorf("could not print passphrase: %v", err)
338
}
339
} else {
340
confirm, err := term.ReadSecret("Confirm passphrase:")
341
if err != nil {
342
return "", fmt.Errorf("could not read passphrase: %v", err)
343
}
344
if string(confirm) != p {
345
return "", fmt.Errorf("passphrases didn't match")
346
}
347 }
348
return p, nil
349 }
350
351
func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader, out io.Writer, armor bool) {
352
var recipients []age.Recipient
353
for _, arg := range recs {
354
r, err := parseRecipient(arg)
355
if err, ok := err.(gitHubRecipientError); ok {
356
errorWithHint(err.Error(), "instead, use recipient files like",
357
" curl -O https://github.com/"+err.username+".keys",
358
" age -R "+err.username+".keys")
359
}
360
if err != nil {
361
errorf("%v", err)
362
}
363
recipients = append(recipients, r)
364 }
365
for _, name := range files {
366
recs, err := parseRecipientsFile(name)
367
if err != nil {
368
errorf("failed to parse recipient file %q: %v", name, err)
369
}
370
recipients = append(recipients, recs...)
371 }
372
for _, f := range identities {
373
switch f.Type {
374
case "i":
375
ids, err := parseIdentitiesFile(f.Value)
376
if err != nil {
377
errorf("reading %q: %v", f.Value, err)
378
}
379
r, err := identitiesToRecipients(ids)
380
if err != nil {
381
errorf("internal error processing %q: %v", f.Value, err)
382
}
383
recipients = append(recipients, r...)
384
case "j":
385
id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
386
if err != nil {
387
errorf("initializing %q: %v", f.Value, err)
388
}
389
recipients = append(recipients, id.Recipient())
390 }
391 }
392
encrypt(recipients, in, out, armor)
393 }
394
395
func encryptPass(in io.Reader, out io.Writer, armor bool) {
396
pass, err := passphrasePromptForEncryption()
397
if err != nil {
398
errorf("%v", err)
399
}
400
401
r, err := age.NewScryptRecipient(pass)
402
if err != nil {
403
errorf("%v", err)
404
}
405
testOnlyConfigureScryptIdentity(r)
406
encrypt([]age.Recipient{r}, in, out, armor)
407 }
408
409
var testOnlyConfigureScryptIdentity = func(*age.ScryptRecipient) {}
410
411
func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor bool) {
412
if withArmor {
413
a := armor.NewWriter(out)
414
defer func() {
415
if err := a.Close(); err != nil {
416
errorf("%v", err)
417
}
418 }()
419
out = a
420 }
421
w, err := age.Encrypt(out, recipients...)
422
if e := new(plugin.NotFoundError); errors.As(err, &e) {
423
errorWithHint(err.Error(),
424
fmt.Sprintf("you might want to install the %q plugin", e.Name),
425
"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
426
} else if err != nil {
427
errorf("%v", err)
428
}
429
if _, err := io.Copy(w, in); err != nil {
430
errorf("%v", err)
431
}
432
if err := w.Close(); err != nil {
433
errorf("%v", err)
434
}
435 }
436
437 // crlfMangledIntro and utf16MangledIntro are the intro lines of the age format
438 // after mangling by various versions of PowerShell redirection, truncated to
439 // the length of the correct intro line. See issue 290.
440 const crlfMangledIntro = "age-encryption.org/v1" + "\r"
441 const utf16MangledIntro = "\xff\xfe" + "a\x00g\x00e\x00-\x00e\x00n\x00c\x00r\x00y\x00p\x00"
442
443 type rejectScryptIdentity struct{}
444
445
func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
446
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
447
return nil, age.ErrIncorrectIdentity
448
}
449
errorWithHint("file is passphrase-encrypted but identities were specified with -i/--identity or -j",
450
"remove all -i/--identity/-j flags to decrypt passphrase-encrypted files")
451
panic("unreachable")
452 }
453
454
func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
455
var identities []age.Identity
456
for _, f := range flags {
457
switch f.Type {
458
case "i":
459
ids, err := parseIdentitiesFile(f.Value)
460
if err != nil {
461
errorf("reading %q: %v", f.Value, err)
462
}
463
identities = append(identities, ids...)
464
case "j":
465
id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
466
if err != nil {
467
errorf("initializing %q: %v", f.Value, err)
468
}
469
identities = append(identities, id)
470 }
471 }
472
identities = append(identities, rejectScryptIdentity{})
473
decrypt(identities, in, out)
474 }
475
476
func decryptPass(in io.Reader, out io.Writer) {
477
identities := []age.Identity{
478
// If there is an scrypt recipient (it will have to be the only one and)
479
// this identity will be invoked.
480
lazyScryptIdentity,
481
}
482
483
decrypt(identities, in, out)
484
}
485
486
func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
487
rr := bufio.NewReader(in)
488
if intro, _ := rr.Peek(len(crlfMangledIntro)); string(intro) == crlfMangledIntro ||
489
string(intro) == utf16MangledIntro {
490
errorWithHint("invalid header intro",
491
"it looks like this file was corrupted by PowerShell redirection",
492
"consider using -o or -a to encrypt files in PowerShell")
493
}
494
495
const maxWhitespace = 1024
496
start, _ := rr.Peek(maxWhitespace + len(armor.Header))
497
if strings.HasPrefix(string(bytes.TrimSpace(start)), armor.Header) {
498
in = armor.NewReader(rr)
499
} else {
500
in = rr
501
}
502
503
r, err := age.Decrypt(in, identities...)
504
if e := new(plugin.NotFoundError); errors.As(err, &e) {
505
errorWithHint(err.Error(),
506
fmt.Sprintf("you might want to install the %q plugin", e.Name),
507
"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
508
} else if errors.As(err, new(*age.NoIdentityMatchError)) &&
509
len(identities) == 1 && identities[0] == lazyScryptIdentity {
510
errorWithHint("the file is not passphrase-encrypted, identities are required",
511
"specify identities with -i/--identity or -j to decrypt this file")
512
} else if err != nil {
513
errorf("%v", err)
514
}
515
out.Write(nil) // trigger the lazyOpener even if r is empty
516
if _, err := io.Copy(out, r); err != nil {
517
errorf("%v", err)
518
}
519 }
520
521 var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption}
522
523
func passphrasePromptForDecryption() (string, error) {
524
pass, err := term.ReadSecret("Enter passphrase:")
525
if err != nil {
526
return "", fmt.Errorf("could not read passphrase: %v", err)
527
}
528
return string(pass), nil
529 }
530
531
func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) {
532
var recipients []age.Recipient
533
for _, id := range ids {
534
switch id := id.(type) {
535
case *age.X25519Identity:
536
recipients = append(recipients, id.Recipient())
537
case *age.HybridIdentity:
538
recipients = append(recipients, id.Recipient())
539
case *plugin.Identity:
540
recipients = append(recipients, id.Recipient())
541
case *agessh.RSAIdentity:
542
recipients = append(recipients, id.Recipient())
543
case *agessh.Ed25519Identity:
544
recipients = append(recipients, id.Recipient())
545
case *agessh.EncryptedSSHIdentity:
546
recipients = append(recipients, id.Recipient())
547
case *EncryptedIdentity:
548
r, err := id.Recipients()
549
if err != nil {
550
return nil, err
551
}
552
recipients = append(recipients, r...)
553
default:
554
return nil, fmt.Errorf("unexpected identity type: %T", id)
555 }
556 }
557
return recipients, nil
558 }
559
560 type lazyOpener struct {
561 name string
562 f *os.File
563 err error
564 }
565
566
func newLazyOpener(name string) io.WriteCloser {
567
return &lazyOpener{name: name}
568
}
569
570
func (l *lazyOpener) Write(p []byte) (n int, err error) {
571
if l.f == nil && l.err == nil {
572
l.f, l.err = os.Create(l.name)
573
}
574
if l.err != nil {
575
return 0, l.err
576
}
577
return l.f.Write(p)
578 }
579
580
func (l *lazyOpener) Close() error {
581
if l.f != nil {
582
return l.f.Close()
583
}
584
return nil
585 }
586
587
func absPath(name string) string {
588
if abs, err := filepath.Abs(name); err == nil {
589
return abs
590
}
591
return name
592 }
593
594
func warnDuplicates(s iter.Seq[string], name string) {
595
seen := make(map[string]bool)
596
warned := make(map[string]bool)
597
for e := range s {
598
if seen[e] && !warned[e] {
599
warningf("duplicate %s %q", name, e)
600
warned[e] = true
601
}
602
seen[e] = true
603 }
604 }
605
filippo.io/age/cmd/age/encrypted_keys.go 10.5%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package main
6
7 import (
8 "bytes"
9 "errors"
10 "fmt"
11
12 "filippo.io/age"
13 )
14
15 // LazyScryptIdentity is an age.Identity that requests a passphrase only if it
16 // encounters an scrypt stanza. After obtaining a passphrase, it delegates to
17 // ScryptIdentity.
18 type LazyScryptIdentity struct {
19 Passphrase func() (string, error)
20 }
21
22 var _ age.Identity = &LazyScryptIdentity{}
23
24
func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
25
for _, s := range stanzas {
26
if s.Type == "scrypt" && len(stanzas) != 1 {
27
return nil, errors.New("an scrypt recipient must be the only one")
28
}
29 }
30
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
31
return nil, age.ErrIncorrectIdentity
32
}
33
pass, err := i.Passphrase()
34
if err != nil {
35
return nil, fmt.Errorf("could not read passphrase: %v", err)
36
}
37
ii, err := age.NewScryptIdentity(pass)
38
if err != nil {
39
return nil, err
40
}
41
fileKey, err = ii.Unwrap(stanzas)
42
if errors.Is(err, age.ErrIncorrectIdentity) {
43
// ScryptIdentity returns ErrIncorrectIdentity for an incorrect
44
// passphrase, which would lead Decrypt to returning "no identity
45
// matched any recipient". That makes sense in the API, where there
46
// might be multiple configured ScryptIdentity. Since in cmd/age there
47
// can be only one, return a better error message.
48
return nil, fmt.Errorf("incorrect passphrase")
49
}
50
return fileKey, err
51 }
52
53 type EncryptedIdentity struct {
54 Contents []byte
55 Passphrase func() (string, error)
56 NoMatchWarning func()
57
58 identities []age.Identity
59 }
60
61 var _ age.Identity = &EncryptedIdentity{}
62
63
func (i *EncryptedIdentity) Recipients() ([]age.Recipient, error) {
64
if i.identities == nil {
65
if err := i.decrypt(); err != nil {
66
return nil, err
67
}
68 }
69
70
return identitiesToRecipients(i.identities)
71 }
72
73
func (i *EncryptedIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
74
if i.identities == nil {
75
if err := i.decrypt(); err != nil {
76
return nil, err
77
}
78 }
79
80
for _, id := range i.identities {
81
fileKey, err = id.Unwrap(stanzas)
82
if errors.Is(err, age.ErrIncorrectIdentity) {
83
continue
84 }
85
if err != nil {
86
return nil, err
87
}
88
return fileKey, nil
89 }
90
i.NoMatchWarning()
91
return nil, age.ErrIncorrectIdentity
92 }
93
94
func (i *EncryptedIdentity) decrypt() error {
95
d, err := age.Decrypt(bytes.NewReader(i.Contents), &LazyScryptIdentity{i.Passphrase})
96
if e := new(age.NoIdentityMatchError); errors.As(err, &e) {
97
return fmt.Errorf("identity file is encrypted with age but not with a passphrase")
98
}
99
if err != nil {
100
return fmt.Errorf("failed to decrypt identity file: %v", err)
101
}
102
i.identities, err = parseIdentities(d)
103
return err
104 }
105
filippo.io/age/cmd/age/parse.go 46.0%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package main
6
7 import (
8 "bufio"
9 "encoding/base64"
10 "fmt"
11 "io"
12 "os"
13 "strings"
14 "unicode/utf8"
15
16 "filippo.io/age"
17 "filippo.io/age/agessh"
18 "filippo.io/age/armor"
19 "filippo.io/age/internal/term"
20 "filippo.io/age/plugin"
21 "filippo.io/age/tag"
22 "golang.org/x/crypto/cryptobyte"
23 "golang.org/x/crypto/ssh"
24 )
25
26 type gitHubRecipientError struct {
27 username string
28 }
29
30
func (gitHubRecipientError) Error() string {
31
return `"github:" recipients were removed from the design`
32
}
33
34
func parseRecipient(arg string) (age.Recipient, error) {
35
switch {
36
case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"):
37
return tag.ParseRecipient(arg)
38
case strings.HasPrefix(arg, "age1pq1"):
39
return age.ParseHybridRecipient(arg)
40
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
41
return plugin.NewRecipient(arg, plugin.NewTerminalUI(printf, warningf))
42
case strings.HasPrefix(arg, "age1"):
43
return age.ParseX25519Recipient(arg)
44
case strings.HasPrefix(arg, "ssh-"):
45
return agessh.ParseRecipient(arg)
46
case strings.HasPrefix(arg, "github:"):
47
name := strings.TrimPrefix(arg, "github:")
48
return nil, gitHubRecipientError{name}
49 }
50
51
return nil, fmt.Errorf("unknown recipient type: %q", arg)
52 }
53
54
func parseRecipientsFile(name string) ([]age.Recipient, error) {
55
var f *os.File
56
if name == "-" {
57
if stdinInUse {
58
return nil, fmt.Errorf("standard input is used for multiple purposes")
59
}
60
stdinInUse = true
61
f = os.Stdin
62
} else {
63
var err error
64
f, err = os.Open(name)
65
if err != nil {
66
return nil, fmt.Errorf("failed to open recipient file: %v", err)
67
}
68
defer f.Close()
69 }
70
71
const recipientFileSizeLimit = 16 << 20 // 16 MiB
72
const lineLengthLimit = 8 << 10 // 8 KiB, same as sshd(8)
73
var recs []age.Recipient
74
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
75
var n int
76
for scanner.Scan() {
77
n++
78
line := scanner.Text()
79
if strings.HasPrefix(line, "#") || line == "" {
80
continue
81 }
82
if !utf8.ValidString(line) {
83
return nil, fmt.Errorf("%q: recipients file is not valid UTF-8", name)
84
}
85
if len(line) > lineLengthLimit {
86
return nil, fmt.Errorf("%q: line %d is too long", name, n)
87
}
88
r, err := parseRecipient(line)
89
if err != nil {
90
if t, ok := sshKeyType(line); ok {
91
// Skip unsupported but valid SSH public keys with a warning.
92
warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n)
93
continue
94 }
95
if strings.HasPrefix(line, "AGE-") {
96
return nil, fmt.Errorf("%q: error at line %d: apparent identity found in recipients file", name, n)
97
}
98 // Hide the error since it might unintentionally leak the contents
99 // of confidential files.
100
return nil, fmt.Errorf("%q: malformed recipient at line %d", name, n)
101 }
102
recs = append(recs, r)
103 }
104
if err := scanner.Err(); err != nil {
105
return nil, fmt.Errorf("%q: failed to read recipients file: %v", name, err)
106
}
107
if len(recs) == 0 {
108
return nil, fmt.Errorf("%q: no recipients found", name)
109
}
110
return recs, nil
111 }
112
113
func sshKeyType(s string) (string, bool) {
114
// TODO: also ignore options? And maybe support multiple spaces and tabs as
115
// field separators like OpenSSH?
116
fields := strings.Split(s, " ")
117
if len(fields) < 2 {
118
return "", false
119
}
120
key, err := base64.StdEncoding.DecodeString(fields[1])
121
if err != nil {
122
return "", false
123
}
124
k := cryptobyte.String(key)
125
var typeLen uint32
126
var typeBytes []byte
127
if !k.ReadUint32(&typeLen) || !k.ReadBytes(&typeBytes, int(typeLen)) {
128
return "", false
129
}
130
if t := fields[0]; t == string(typeBytes) {
131
return t, true
132
}
133
return "", false
134 }
135
136 // parseIdentitiesFile parses a file that contains age or SSH keys. It returns
137 // one or more of *[age.X25519Identity], *[age.HybridIdentity],
138 // *[agessh.RSAIdentity], *[agessh.Ed25519Identity],
139 // *[agessh.EncryptedSSHIdentity], or *[EncryptedIdentity].
140
func parseIdentitiesFile(name string) ([]age.Identity, error) {
141
var f *os.File
142
if name == "-" {
143
if stdinInUse {
144
return nil, fmt.Errorf("standard input is used for multiple purposes")
145
}
146
stdinInUse = true
147
f = os.Stdin
148
} else {
149
var err error
150
f, err = os.Open(name)
151
if err != nil {
152
return nil, fmt.Errorf("failed to open file: %v", err)
153
}
154
defer f.Close()
155 }
156
157
b := bufio.NewReader(f)
158
p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
159
peeked := string(p)
160
161
switch {
162 // An age encrypted file, plain or armored.
163
case peeked == "age-encryption" || peeked == "-----BEGIN AGE":
164
var r io.Reader = b
165
if peeked == "-----BEGIN AGE" {
166
r = armor.NewReader(r)
167
}
168
const privateKeySizeLimit = 1 << 24 // 16 MiB
169
contents, err := io.ReadAll(io.LimitReader(r, privateKeySizeLimit))
170
if err != nil {
171
return nil, fmt.Errorf("failed to read %q: %v", name, err)
172
}
173
if len(contents) == privateKeySizeLimit {
174
return nil, fmt.Errorf("failed to read %q: file too long", name)
175
}
176
return []age.Identity{&EncryptedIdentity{
177
Contents: contents,
178
Passphrase: func() (string, error) {
179
pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name))
180
if err != nil {
181
return "", fmt.Errorf("could not read passphrase: %v", err)
182
}
183
return string(pass), nil
184 },
185
NoMatchWarning: func() {
186
warningf("encrypted identity file %q didn't match file's recipients", name)
187
},
188 }}, nil
189
190 // Another PEM file, possibly an SSH private key.
191
case strings.HasPrefix(peeked, "-----BEGIN"):
192
const privateKeySizeLimit = 1 << 14 // 16 KiB
193
contents, err := io.ReadAll(io.LimitReader(b, privateKeySizeLimit))
194
if err != nil {
195
return nil, fmt.Errorf("failed to read %q: %v", name, err)
196
}
197
if len(contents) == privateKeySizeLimit {
198
return nil, fmt.Errorf("failed to read %q: file too long", name)
199
}
200
return parseSSHIdentity(name, contents)
201
202 // An unencrypted age identity file.
203
default:
204
ids, err := parseIdentities(b)
205
if err != nil {
206
return nil, fmt.Errorf("failed to read %q: %v", name, err)
207
}
208
return ids, nil
209 }
210 }
211
212
func parseIdentity(s string) (age.Identity, error) {
213
switch {
214
case strings.HasPrefix(s, "AGE-PLUGIN-"):
215
return plugin.NewIdentity(s, plugin.NewTerminalUI(printf, warningf))
216
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
217
return age.ParseX25519Identity(s)
218
case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
219
return age.ParseHybridIdentity(s)
220
default:
221
return nil, fmt.Errorf("unknown identity type")
222 }
223 }
224
225 // parseIdentities is like [age.ParseIdentities], but supports plugin identities.
226
func parseIdentities(f io.Reader) ([]age.Identity, error) {
227
const privateKeySizeLimit = 1 << 24 // 16 MiB
228
var ids []age.Identity
229
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
230
var n int
231
for scanner.Scan() {
232
n++
233
line := scanner.Text()
234
if strings.HasPrefix(line, "#") || line == "" {
235
continue
236 }
237
if !utf8.ValidString(line) {
238
return nil, fmt.Errorf("identities file is not valid UTF-8")
239
}
240
i, err := parseIdentity(line)
241
if err != nil {
242
if strings.HasPrefix(line, "age1") {
243
return nil, fmt.Errorf("error at line %d: apparent recipient found in identities file", n)
244
}
245
return nil, fmt.Errorf("error at line %d: %v", n, err)
246 }
247
ids = append(ids, i)
248 }
249
if err := scanner.Err(); err != nil {
250
return nil, fmt.Errorf("failed to read identities file: %v", err)
251
}
252
if len(ids) == 0 {
253
return nil, fmt.Errorf("no identities found")
254
}
255
return ids, nil
256 }
257
258
func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
259
id, err := agessh.ParseIdentity(pemBytes)
260
if sshErr, ok := err.(*ssh.PassphraseMissingError); ok {
261
pubKey := sshErr.PublicKey
262
if pubKey == nil {
263
pubKey, err = readPubFile(name)
264
if err != nil {
265
return nil, err
266
}
267 }
268
passphrasePrompt := func() ([]byte, error) {
269
pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for %q:", name))
270
if err != nil {
271
return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err)
272
}
273
return pass, nil
274 }
275
i, err := agessh.NewEncryptedSSHIdentity(pubKey, pemBytes, passphrasePrompt)
276
if err != nil {
277
return nil, err
278
}
279
return []age.Identity{i}, nil
280 }
281
if err != nil {
282
return nil, fmt.Errorf("malformed SSH identity in %q: %v", name, err)
283
}
284
285
return []age.Identity{id}, nil
286 }
287
288
func readPubFile(name string) (ssh.PublicKey, error) {
289
if name == "-" {
290
return nil, fmt.Errorf(`failed to obtain public key for "-" SSH key
291
292
Use a file for which the corresponding ".pub" file exists, or convert the private key to a modern format with "ssh-keygen -p -m RFC4716"`)
293
}
294
f, err := os.Open(name + ".pub")
295
if err != nil {
296
return nil, fmt.Errorf(`failed to obtain public key for %q SSH key: %v
297
298
Ensure %q exists, or convert the private key %q to a modern format with "ssh-keygen -p -m RFC4716"`, name, err, name+".pub", name)
299
}
300
defer f.Close()
301
contents, err := io.ReadAll(f)
302
if err != nil {
303
return nil, fmt.Errorf("failed to read %q: %v", name+".pub", err)
304
}
305
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents)
306
if err != nil {
307
return nil, fmt.Errorf("failed to parse %q: %v", name+".pub", err)
308
}
309
return pubKey, nil
310 }
311
filippo.io/age/cmd/age/tui.go 42.9%
1 // Copyright 2021 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package main
6
7 // This file implements the terminal UI of cmd/age. The rules are:
8 //
9 // - Anything that requires user interaction goes to the terminal,
10 // and is erased afterwards if possible. This UI would be possible
11 // to replace with a pinentry with no output or UX changes.
12 //
13 // - Everything else goes to standard error with an "age:" prefix.
14 // No capitalized initials and no periods at the end.
15 //
16 // The one exception is the autogenerated passphrase, which goes to
17 // the terminal, since we really want it to reach the user only.
18
19 import (
20 "bytes"
21 "fmt"
22 "io"
23 "log"
24 "os"
25
26 "filippo.io/age/armor"
27 "filippo.io/age/internal/term"
28 )
29
30 // l is a logger with no prefixes.
31 var l = log.New(os.Stderr, "", 0)
32
33
func printf(format string, v ...any) {
34
l.Printf("age: "+format, v...)
35
}
36
37
func errorf(format string, v ...any) {
38
l.Printf("age: error: "+format, v...)
39
l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
40
os.Exit(1)
41
}
42
43
func warningf(format string, v ...any) {
44
l.Printf("age: warning: "+format, v...)
45
}
46
47
func errorWithHint(error string, hints ...string) {
48
l.Printf("age: error: %s", error)
49
for _, hint := range hints {
50
l.Printf("age: hint: %s", hint)
51
}
52
l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
53
os.Exit(1)
54 }
55
56
func printfToTerminal(format string, v ...any) error {
57
return term.WithTerminal(func(_, out *os.File) error {
58
_, err := fmt.Fprintf(out, "age: "+format+"\n", v...)
59
return err
60
})
61 }
62
63
func bufferTerminalInput(in io.Reader) (io.Reader, error) {
64
buf := &bytes.Buffer{}
65
if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) {
66
if bytes.Contains(buf.Bytes(), []byte(armor.Footer+"\n")) {
67
return 0, io.EOF
68
}
69
return in.Read(p)
70
})); err != nil {
71
return nil, err
72
}
73
return buf, nil
74 }
75
76 type ReaderFunc func(p []byte) (n int, err error)
77
78
func (f ReaderFunc) Read(p []byte) (n int, err error) { return f(p) }
79
filippo.io/age/cmd/age/wordlist.go 0.0%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package main
6
7 import (
8 "crypto/rand"
9 "encoding/binary"
10 "strings"
11 )
12
13 var testOnlyFixedRandomWord string
14
15
func randomWord() string {
16
if testOnlyFixedRandomWord != "" {
17
return testOnlyFixedRandomWord
18
}
19
buf := make([]byte, 2)
20
if _, err := rand.Read(buf); err != nil {
21
panic(err)
22 }
23
n := binary.BigEndian.Uint16(buf)
24
return wordlist[int(n)%2048]
25 }
26
27 // wordlist is the BIP39 list of 2048 english words, and it's used to generate
28 // the suggested passphrases.
29 var wordlist = strings.Split(`abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artefact artist artwork ask aspect assault asset assist assume asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp can canal cancel candy cannon canoe canvas canyon capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current curtain curve cushion custom cute cycle dad damage damp dance danger daring dash daughter dawn day deal debate debris decade december decide decline decorate decrease deer defense define defy degree delay deliver demand demise denial dentist deny depart depend deposit depth deputy derive describe desert design desk despair destroy detail detect develop device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance divert divide divorce dizzy doctor document dog doll dolphin domain donate donkey donor door dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop drum dry duck dumb dune during dust dutch duty dwarf dynamic eager eagle early earn earth easily east easy echo ecology economy edge edit educate effort egg eight either elbow elder electric elegant element elephant elevator elite else embark embody embrace emerge emotion employ empower empty enable enact end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence evil evoke evolve exact example excess exchange excite exclude excuse execute exercise exhaust exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye eyebrow fabric face faculty fade faint faith fall false fame family famous fan fancy fantasy farm fashion fat fatal father fatigue fault favorite feature february federal fee feed feel female fence festival fetch fever few fiber fiction field figure file film filter final find fine finger finish fire firm first fiscal fish fit fitness fix flag flame flash flat flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold follow food foot force forest forget fork fortune forum forward fossil foster found fox fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny furnace fury future gadget gain galaxy gallery game gap garage garbage garden garlic garment gas gasp gate gather gauge gaze general genius genre gentle genuine gesture ghost giant gift giggle ginger giraffe girl give glad glance glare glass glide glimpse globe gloom glory glove glow glue goat goddess gold good goose gorilla gospel gossip govern gown grab grace grain grant grape grass gravity great green grid grief grit grocery group grow grunt guard guess guide guilt guitar gun gym habit hair half hammer hamster hand happy harbor hard harsh harvest hat have hawk hazard head health heart heavy hedgehog height hello helmet help hen hero hidden high hill hint hip hire history hobby hockey hold hole holiday hollow home honey hood hope horn horror horse hospital host hotel hour hover hub huge human humble humor hundred hungry hunt hurdle hurry hurt husband hybrid ice icon idea identify idle ignore ill illegal illness image imitate immense immune impact impose improve impulse inch include income increase index indicate indoor industry infant inflict inform inhale inherit initial inject injury inmate inner innocent input inquiry insane insect inside inspire install intact interest into invest invite involve iron island isolate issue item ivory jacket jaguar jar jazz jealous jeans jelly jewel job join joke journey joy judge juice jump jungle junior junk just kangaroo keen keep ketchup key kick kid kidney kind kingdom kiss kit kitchen kite kitten kiwi knee knife knock know lab label labor ladder lady lake lamp language laptop large later latin laugh laundry lava law lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal legend leisure lemon lend length lens leopard lesson letter level liar liberty library license life lift light like limb limit link lion liquid list little live lizard load loan lobster local lock logic lonely long loop lottery loud lounge love loyal lucky luggage lumber lunar lunch luxury lyrics machine mad magic magnet maid mail main major make mammal man manage mandate mango mansion manual maple marble march margin marine market marriage mask mass master match material math matrix matter maximum maze meadow mean measure meat mechanic medal media melody melt member memory mention menu mercy merge merit merry mesh message metal method middle midnight milk million mimic mind minimum minor minute miracle mirror misery miss mistake mix mixed mixture mobile model modify mom moment monitor monkey monster month moon moral more morning mosquito mother motion motor mountain mouse move movie much muffin mule multiply muscle museum mushroom music must mutual myself mystery myth naive name napkin narrow nasty nation nature near neck need negative neglect neither nephew nerve nest net network neutral never news next nice night noble noise nominee noodle normal north nose notable note nothing notice novel now nuclear number nurse nut oak obey object oblige obscure observe obtain obvious occur ocean october odor off offer office often oil okay old olive olympic omit once one onion online only open opera opinion oppose option orange orbit orchard order ordinary organ orient original orphan ostrich other outdoor outer output outside oval oven over own owner oxygen oyster ozone pact paddle page pair palace palm panda panel panic panther paper parade parent park parrot party pass patch path patient patrol pattern pause pave payment peace peanut pear peasant pelican pen penalty pencil people pepper perfect permit person pet phone photo phrase physical piano picnic picture piece pig pigeon pill pilot pink pioneer pipe pistol pitch pizza place planet plastic plate play please pledge pluck plug plunge poem poet point polar pole police pond pony pool popular portion position possible post potato pottery poverty powder power practice praise predict prefer prepare present pretty prevent price pride primary print priority prison private prize problem process produce profit program project promote proof property prosper protect proud provide public pudding pull pulp pulse pumpkin punch pupil puppy purchase purity purpose purse push put puzzle pyramid quality quantum quarter question quick quit quiz quote rabbit raccoon race rack radar radio rail rain raise rally ramp ranch random range rapid rare rate rather raven raw razor ready real reason rebel rebuild recall receive recipe record recycle reduce reflect reform refuse region regret regular reject relax release relief rely remain remember remind remove render renew rent reopen repair repeat replace report require rescue resemble resist resource response result retire retreat return reunion reveal review reward rhythm rib ribbon rice rich ride ridge rifle right rigid ring riot ripple risk ritual rival river road roast robot robust rocket romance roof rookie room rose rotate rough round route royal rubber rude rug rule run runway rural sad saddle sadness safe sail salad salmon salon salt salute same sample sand satisfy satoshi sauce sausage save say scale scan scare scatter scene scheme school science scissors scorpion scout scrap screen script scrub sea search season seat second secret section security seed seek segment select sell seminar senior sense sentence series service session settle setup seven shadow shaft shallow share shed shell sheriff shield shift shine ship shiver shock shoe shoot shop short shoulder shove shrimp shrug shuffle shy sibling sick side siege sight sign silent silk silly silver similar simple since sing siren sister situate six size skate sketch ski skill skin skirt skull slab slam sleep slender slice slide slight slim slogan slot slow slush small smart smile smoke smooth snack snake snap sniff snow soap soccer social sock soda soft solar soldier solid solution solve someone song soon sorry sort soul sound soup source south space spare spatial spawn speak special speed spell spend sphere spice spider spike spin spirit split spoil sponsor spoon sport spot spray spread spring spy square squeeze squirrel stable stadium staff stage stairs stamp stand start state stay steak steel stem step stereo stick still sting stock stomach stone stool story stove strategy street strike strong struggle student stuff stumble style subject submit subway success such sudden suffer sugar suggest suit summer sun sunny sunset super supply supreme sure surface surge surprise surround survey suspect sustain swallow swamp swap swarm swear sweet swift swim swing switch sword symbol symptom syrup system table tackle tag tail talent talk tank tape target task taste tattoo taxi teach team tell ten tenant tennis tent term test text thank that theme then theory there they thing this thought three thrive throw thumb thunder ticket tide tiger tilt timber time tiny tip tired tissue title toast tobacco today toddler toe together toilet token tomato tomorrow tone tongue tonight tool tooth top topic topple torch tornado tortoise toss total tourist toward tower town toy track trade traffic tragic train transfer trap trash travel tray treat tree trend trial tribe trick trigger trim trip trophy trouble truck true truly trumpet trust truth try tube tuition tumble tuna tunnel turkey turn turtle twelve twenty twice twin twist two type typical ugly umbrella unable unaware uncle uncover under undo unfair unfold unhappy uniform unique unit universe unknown unlock until unusual unveil update upgrade uphold upon upper upset urban urge usage use used useful useless usual utility vacant vacuum vague valid valley valve van vanish vapor various vast vault vehicle velvet vendor venture venue verb verify version very vessel veteran viable vibrant vicious victory video view village vintage violin virtual virus visa visit visual vital vivid vocal voice void volcano volume vote voyage wage wagon wait walk wall walnut want warfare warm warrior wash wasp waste water wave way wealth weapon wear weasel weather web wedding weekend weird welcome west wet whale what wheat wheel when where whip whisper wide width wife wild will win window wine wing wink winner winter wire wisdom wise wish witness wolf woman wonder wood wool word work world worry worth wrap wreck wrestle wrist write wrong yard year yellow you young youth zebra zero zone zoo`, " ")
30
filippo.io/age/extra/age-plugin-pq/plugin-pq.go 0.0%
1 package main
2
3 import (
4 "flag"
5 "fmt"
6 "io"
7 "log"
8 "os"
9 "runtime/debug"
10
11 "filippo.io/age"
12 "filippo.io/age/internal/bech32"
13 "filippo.io/age/plugin"
14 )
15
16 const usage = `Usage:
17 age-plugin-pq -identity [-o OUTPUT] [INPUT]
18
19 Options:
20 -identity Convert one or more native post-quantum identities from
21 INPUT or from standard input to plugin identities.
22 -o, --output OUTPUT Write the result to the file at path OUTPUT instead of
23 standard output.
24
25 age-plugin-pq is an age plugin for post-quantum hybrid ML-KEM-768 + X25519
26 recipients and identities. These are supported natively by age v1.3.0 and later,
27 but this plugin can be placed in $PATH to add support to any version and
28 implementation of age that supports plugins.
29
30 Recipients work out of the box, while identities need to be converted to plugin
31 identities with -identity. If OUTPUT already exists, it is not overwritten.`
32
33 // Version can be set at link time to override debug.BuildInfo.Main.Version when
34 // building manually without git history. It should look like "v1.2.3".
35 var Version string
36
37
func main() {
38
log.SetFlags(0)
39
40
p, err := plugin.New("pq")
41
if err != nil {
42
errorf("failed to create plugin: %v", err)
43
}
44
p.RegisterFlags(nil)
45
46
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
47
48
var outFlag string
49
var versionFlag, identityFlag bool
50
flag.BoolVar(&versionFlag, "version", false, "print the version")
51
flag.BoolVar(&identityFlag, "identity", false, "convert identities to plugin identities")
52
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
53
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
54
flag.Parse()
55
56
if versionFlag {
57
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
58
Version = buildInfo.Main.Version
59
}
60
fmt.Println(Version)
61
return
62 }
63
64
if identityFlag {
65
if len(flag.Args()) > 1 {
66
errorf("too many arguments")
67
}
68
69
out := os.Stdout
70
if outFlag != "" {
71
f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
72
if err != nil {
73
errorf("failed to open output file %q: %v", outFlag, err)
74
}
75
defer func() {
76
if err := f.Close(); err != nil {
77
errorf("failed to close output file %q: %v", outFlag, err)
78
}
79 }()
80
out = f
81 }
82
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
83
warning("writing secret key to a world-readable file")
84
}
85
86
in := os.Stdin
87
if inFile := flag.Arg(0); inFile != "" && inFile != "-" {
88
f, err := os.Open(inFile)
89
if err != nil {
90
errorf("failed to open input file %q: %v", inFile, err)
91
}
92
defer f.Close()
93
in = f
94 }
95
96
convert(in, out)
97
return
98 }
99
100
p.HandleRecipientEncoding(func(s string) (age.Recipient, error) {
101
return age.ParseHybridRecipient(s)
102
})
103
p.HandleIdentity(func(data []byte) (age.Identity, error) {
104
// Convert from a AGE-PLUGIN-PQ-1... payload to a
105
// AGE-SECRET-KEY-PQ-1... identity encoding.
106
s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data)
107
if err != nil {
108
return nil, err
109
}
110
return age.ParseHybridIdentity(s)
111 })
112
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
113
s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data)
114
if err != nil {
115
return nil, err
116
}
117
i, err := age.ParseHybridIdentity(s)
118
if err != nil {
119
return nil, err
120
}
121
return i.Recipient(), nil
122 })
123
os.Exit(p.Main())
124 }
125
126
func convert(in io.Reader, out io.Writer) {
127
ids, err := age.ParseIdentities(in)
128
if err != nil {
129
errorf("failed to parse identities: %v", err)
130
}
131
for i, id := range ids {
132
hybridID, ok := id.(*age.HybridIdentity)
133
if !ok {
134
errorf("identity #%d is not a post-quantum hybrid identity", i+1)
135
}
136
_, data, err := bech32.Decode(hybridID.String())
137
if err != nil {
138
errorf("failed to decode identity #%d: %v", i+1, err)
139
}
140
fmt.Fprintln(out, plugin.EncodeIdentity("pq", data))
141 }
142 }
143
144
func errorf(format string, v ...any) {
145
log.Printf("age-plugin-pq: error: "+format, v...)
146
log.Fatalf("age-plugin-pq: report unexpected or unhelpful errors at https://filippo.io/age/report")
147
}
148
149
func warning(msg string) {
150
log.Printf("age-plugin-pq: warning: %s", msg)
151
}
152
filippo.io/age/extra/age-plugin-tag/plugin-tag.go 0.0%
1 package main
2
3 import (
4 "flag"
5 "fmt"
6 "log"
7 "os"
8 "runtime/debug"
9
10 "filippo.io/age"
11 "filippo.io/age/plugin"
12 "filippo.io/age/tag"
13 )
14
15 const usage = `age-plugin-tag is an age plugin for P-256 tagged recipients. These are supported
16 natively by age v1.3.0 and later, but this plugin can be placed in $PATH to add
17 support to any version and implementation of age that supports plugins.
18
19 Usually, tagged recipients are the public side of private keys held in hardware,
20 where the identity side is handled by a different plugin.`
21
22 // Version can be set at link time to override debug.BuildInfo.Main.Version when
23 // building manually without git history. It should look like "v1.2.3".
24 var Version string
25
26
func main() {
27
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
28
29
p, err := plugin.New("tag")
30
if err != nil {
31
log.Fatal(err)
32
}
33
p.RegisterFlags(nil)
34
35
versionFlag := flag.Bool("version", false, "print the version")
36
flag.Parse()
37
38
if *versionFlag {
39
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
40
Version = buildInfo.Main.Version
41
}
42
fmt.Println(Version)
43
return
44 }
45
46
p.HandleRecipient(func(b []byte) (age.Recipient, error) {
47
return tag.NewClassicRecipient(b)
48
})
49
50
os.Exit(p.Main())
51 }
52
filippo.io/age/extra/age-plugin-tagpq/plugin-tagpq.go 0.0%
1 package main
2
3 import (
4 "flag"
5 "fmt"
6 "log"
7 "os"
8 "runtime/debug"
9
10 "filippo.io/age"
11 "filippo.io/age/plugin"
12 "filippo.io/age/tag"
13 )
14
15 const usage = `age-plugin-tagpq is an age plugin for ML-KEM-768 + P-256 post-quantum hybrid
16 tagged recipients. These are supported natively by age v1.3.0 and later, but
17 this plugin can be placed in $PATH to add support to any version and
18 implementation of age that supports plugins.
19
20 Usually, tagged recipients are the public side of private keys held in hardware,
21 where the identity side is handled by a different plugin.`
22
23 // Version can be set at link time to override debug.BuildInfo.Main.Version when
24 // building manually without git history. It should look like "v1.2.3".
25 var Version string
26
27
func main() {
28
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
29
30
p, err := plugin.New("tagpq")
31
if err != nil {
32
log.Fatal(err)
33
}
34
p.RegisterFlags(nil)
35
36
versionFlag := flag.Bool("version", false, "print the version")
37
flag.Parse()
38
39
if *versionFlag {
40
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
41
Version = buildInfo.Main.Version
42
}
43
fmt.Println(Version)
44
return
45 }
46
47
p.HandleRecipient(func(b []byte) (age.Recipient, error) {
48
return tag.NewHybridRecipient(b)
49
})
50
51
os.Exit(p.Main())
52 }
53
filippo.io/age/internal/bech32/bech32.go 91.1%
1 // Copyright (c) 2017 Takatoshi Nakagawa
2 // Copyright (c) 2019 The age Authors
3 //
4 // Permission is hereby granted, free of charge, to any person obtaining a copy
5 // of this software and associated documentation files (the "Software"), to deal
6 // in the Software without restriction, including without limitation the rights
7 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 // copies of the Software, and to permit persons to whom the Software is
9 // furnished to do so, subject to the following conditions:
10 //
11 // The above copyright notice and this permission notice shall be included in
12 // all copies or substantial portions of the Software.
13 //
14 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 // THE SOFTWARE.
21
22 // Package bech32 is a modified version of the reference implementation of BIP173.
23 package bech32
24
25 import (
26 "fmt"
27 "strings"
28 )
29
30 var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
31
32 var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
33
34
func polymod(values []byte) uint32 {
35
chk := uint32(1)
36
for _, v := range values {
37
top := chk >> 25
38
chk = (chk & 0x1ffffff) << 5
39
chk = chk ^ uint32(v)
40
for i := range 5 {
41
bit := top >> i & 1
42
if bit == 1 {
43
chk ^= generator[i]
44
}
45 }
46 }
47
return chk
48 }
49
50
func hrpExpand(hrp string) []byte {
51
h := []byte(strings.ToLower(hrp))
52
var ret []byte
53
for _, c := range h {
54
ret = append(ret, c>>5)
55
}
56
ret = append(ret, 0)
57
for _, c := range h {
58
ret = append(ret, c&31)
59
}
60
return ret
61 }
62
63
func verifyChecksum(hrp string, data []byte) bool {
64
return polymod(append(hrpExpand(hrp), data...)) == 1
65
}
66
67
func createChecksum(hrp string, data []byte) []byte {
68
values := append(hrpExpand(hrp), data...)
69
values = append(values, []byte{0, 0, 0, 0, 0, 0}...)
70
mod := polymod(values) ^ 1
71
ret := make([]byte, 6)
72
for p := range ret {
73
shift := 5 * (5 - p)
74
ret[p] = byte(mod>>shift) & 31
75
}
76
return ret
77 }
78
79
func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) {
80
var ret []byte
81
acc := uint32(0)
82
bits := byte(0)
83
maxv := byte(1<<tobits - 1)
84
for idx, value := range data {
85
if value>>frombits != 0 {
86
return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits)
87
}
88
acc = acc<<frombits | uint32(value)
89
bits += frombits
90
for bits >= tobits {
91
bits -= tobits
92
ret = append(ret, byte(acc>>bits)&maxv)
93
}
94 }
95
if pad {
96
if bits > 0 {
97
ret = append(ret, byte(acc<<(tobits-bits))&maxv)
98
}
99
} else if bits >= frombits {
100
return nil, fmt.Errorf("illegal zero padding")
101
} else if byte(acc<<(tobits-bits))&maxv != 0 {
102
return nil, fmt.Errorf("non-zero padding")
103
}
104
return ret, nil
105 }
106
107 // Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase,
108 // the output will be uppercase.
109
func Encode(hrp string, data []byte) (string, error) {
110
values, err := convertBits(data, 8, 5, true)
111
if err != nil {
112
return "", err
113
}
114
if len(hrp) < 1 {
115
return "", fmt.Errorf("invalid HRP: %q", hrp)
116
}
117
for p, c := range hrp {
118
if c < 33 || c > 126 {
119
return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c)
120
}
121 }
122
if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp {
123
return "", fmt.Errorf("mixed case HRP: %q", hrp)
124
}
125
lower := strings.ToLower(hrp) == hrp
126
hrp = strings.ToLower(hrp)
127
var ret strings.Builder
128
ret.WriteString(hrp)
129
ret.WriteString("1")
130
for _, p := range values {
131
ret.WriteByte(charset[p])
132
}
133
for _, p := range createChecksum(hrp, values) {
134
ret.WriteByte(charset[p])
135
}
136
if lower {
137
return ret.String(), nil
138
}
139
return strings.ToUpper(ret.String()), nil
140 }
141
142 // Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase.
143
func Decode(s string) (hrp string, data []byte, err error) {
144
if strings.ToLower(s) != s && strings.ToUpper(s) != s {
145
return "", nil, fmt.Errorf("mixed case")
146
}
147
pos := strings.LastIndex(s, "1")
148
if pos < 1 || pos+7 > len(s) {
149
return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s))
150
}
151
hrp = s[:pos]
152
for p, c := range hrp {
153
if c < 33 || c > 126 {
154
return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c)
155
}
156 }
157
s = strings.ToLower(s)
158
for p, c := range s[pos+1:] {
159
d := strings.IndexRune(charset, c)
160
if d == -1 {
161
return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c)
162
}
163
data = append(data, byte(d))
164 }
165
if !verifyChecksum(hrp, data) {
166
return "", nil, fmt.Errorf("invalid checksum")
167
}
168
data, err = convertBits(data[:len(data)-6], 5, 8, false)
169
if err != nil {
170
return "", nil, err
171
}
172
return hrp, data, nil
173 }
174
filippo.io/age/internal/format/format.go 19.4%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 // Package format implements the age file format.
6 package format
7
8 import (
9 "bufio"
10 "bytes"
11 "encoding/base64"
12 "errors"
13 "fmt"
14 "io"
15 "strings"
16 )
17
18 type Header struct {
19 Recipients []*Stanza
20 MAC []byte
21 }
22
23 // Stanza is assignable to age.Stanza, and if this package is made public,
24 // age.Stanza can be made a type alias of this type.
25 type Stanza struct {
26 Type string
27 Args []string
28 Body []byte
29 }
30
31 var b64 = base64.RawStdEncoding.Strict()
32
33
func DecodeString(s string) ([]byte, error) {
34
// CR and LF are ignored by DecodeString, but we don't want any malleability.
35
if strings.ContainsAny(s, "\n\r") {
36
return nil, errors.New(`unexpected newline character`)
37
}
38
return b64.DecodeString(s)
39 }
40
41 var EncodeToString = b64.EncodeToString
42
43 const ColumnsPerLine = 64
44
45 const BytesPerLine = ColumnsPerLine / 4 * 3
46
47 // NewWrappedBase64Encoder returns a WrappedBase64Encoder that writes to dst.
48
func NewWrappedBase64Encoder(enc *base64.Encoding, dst io.Writer) *WrappedBase64Encoder {
49
w := &WrappedBase64Encoder{dst: dst}
50
w.enc = base64.NewEncoder(enc, WriterFunc(w.writeWrapped))
51
return w
52
}
53
54 type WriterFunc func(p []byte) (int, error)
55
56
func (f WriterFunc) Write(p []byte) (int, error) { return f(p) }
57
58 // WrappedBase64Encoder is a standard base64 encoder that inserts an LF
59 // character every ColumnsPerLine bytes. It does not insert a newline neither at
60 // the beginning nor at the end of the stream, but it ensures the last line is
61 // shorter than ColumnsPerLine, which means it might be empty.
62 type WrappedBase64Encoder struct {
63 enc io.WriteCloser
64 dst io.Writer
65 written int
66 buf bytes.Buffer
67 }
68
69
func (w *WrappedBase64Encoder) Write(p []byte) (int, error) { return w.enc.Write(p) }
70
71
func (w *WrappedBase64Encoder) Close() error {
72
return w.enc.Close()
73
}
74
75
func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
76
if w.buf.Len() != 0 {
77
panic("age: internal error: non-empty WrappedBase64Encoder.buf")
78 }
79
for len(p) > 0 {
80
toWrite := min(ColumnsPerLine-(w.written%ColumnsPerLine), len(p))
81
n, _ := w.buf.Write(p[:toWrite])
82
w.written += n
83
p = p[n:]
84
if w.written%ColumnsPerLine == 0 {
85
w.buf.Write([]byte("\n"))
86
}
87 }
88
if _, err := w.buf.WriteTo(w.dst); err != nil {
89
// We always return n = 0 on error because it's hard to work back to the
90
// input length that ended up written out. Not ideal, but Write errors
91
// are not recoverable anyway.
92
return 0, err
93
}
94
return len(p), nil
95 }
96
97 // LastLineIsEmpty returns whether the last output line was empty, either
98 // because no input was written, or because a multiple of BytesPerLine was.
99 //
100 // Calling LastLineIsEmpty before Close is meaningless.
101
func (w *WrappedBase64Encoder) LastLineIsEmpty() bool {
102
return w.written%ColumnsPerLine == 0
103
}
104
105 const intro = "age-encryption.org/v1\n"
106
107 var stanzaPrefix = []byte("->")
108 var footerPrefix = []byte("---")
109
110
func (r *Stanza) Marshal(w io.Writer) error {
111
if _, err := w.Write(stanzaPrefix); err != nil {
112
return err
113
}
114
for _, a := range append([]string{r.Type}, r.Args...) {
115
if _, err := io.WriteString(w, " "+a); err != nil {
116
return err
117
}
118 }
119
if _, err := io.WriteString(w, "\n"); err != nil {
120
return err
121
}
122
ww := NewWrappedBase64Encoder(b64, w)
123
if _, err := ww.Write(r.Body); err != nil {
124
return err
125
}
126
if err := ww.Close(); err != nil {
127
return err
128
}
129
_, err := io.WriteString(w, "\n")
130
return err
131 }
132
133
func (h *Header) MarshalWithoutMAC(w io.Writer) error {
134
if _, err := io.WriteString(w, intro); err != nil {
135
return err
136
}
137
for _, r := range h.Recipients {
138
if err := r.Marshal(w); err != nil {
139
return err
140
}
141 }
142
_, err := fmt.Fprintf(w, "%s", footerPrefix)
143
return err
144 }
145
146
func (h *Header) Marshal(w io.Writer) error {
147
if err := h.MarshalWithoutMAC(w); err != nil {
148
return err
149
}
150
mac := b64.EncodeToString(h.MAC)
151
_, err := fmt.Fprintf(w, " %s\n", mac)
152
return err
153 }
154
155 type StanzaReader struct {
156 r *bufio.Reader
157 err error
158 }
159
160
func NewStanzaReader(r *bufio.Reader) *StanzaReader {
161
return &StanzaReader{r: r}
162
}
163
164
func (r *StanzaReader) ReadStanza() (s *Stanza, err error) {
165
// Read errors are unrecoverable.
166
if r.err != nil {
167
return nil, r.err
168
}
169
defer func() { r.err = err }()
170
171
s = &Stanza{}
172
173
line, err := r.r.ReadBytes('\n')
174
if err != nil {
175
return nil, fmt.Errorf("failed to read line: %w", err)
176
}
177
if !bytes.HasPrefix(line, stanzaPrefix) {
178
return nil, fmt.Errorf("malformed stanza opening line: %q", line)
179
}
180
prefix, args := splitArgs(line)
181
if prefix != string(stanzaPrefix) || len(args) < 1 {
182
return nil, fmt.Errorf("malformed stanza: %q", line)
183
}
184
for _, a := range args {
185
if !isValidString(a) {
186
return nil, fmt.Errorf("malformed stanza: %q", line)
187
}
188 }
189
s.Type = args[0]
190
s.Args = args[1:]
191
192
for {
193
line, err := r.r.ReadBytes('\n')
194
if err != nil {
195
return nil, fmt.Errorf("failed to read line: %w", err)
196
}
197
198
b, err := DecodeString(strings.TrimSuffix(string(line), "\n"))
199
if err != nil {
200
if bytes.HasPrefix(line, footerPrefix) || bytes.HasPrefix(line, stanzaPrefix) {
201
return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line\nnote: this might be a file encrypted with an old beta version of age or rage; use age v1.0.0-beta6 or rage to decrypt it", line)
202
}
203
return nil, errorf("malformed body line %q: %v", line, err)
204 }
205
if len(b) > BytesPerLine {
206
return nil, errorf("malformed body line %q: too long", line)
207
}
208
s.Body = append(s.Body, b...)
209
if len(b) < BytesPerLine {
210
// A stanza body always ends with a short line.
211
return s, nil
212
}
213 }
214 }
215
216 type ParseError struct {
217 err error
218 }
219
220
func (e *ParseError) Error() string {
221
return "parsing age header: " + e.err.Error()
222
}
223
224
func (e *ParseError) Unwrap() error {
225
return e.err
226
}
227
228
func errorf(format string, a ...any) error {
229
return &ParseError{fmt.Errorf(format, a...)}
230
}
231
232 // Parse returns the header and a Reader that begins at the start of the
233 // payload.
234
func Parse(input io.Reader) (*Header, io.Reader, error) {
235
h := &Header{}
236
rr := bufio.NewReader(input)
237
238
line, err := rr.ReadString('\n')
239
if err == io.EOF {
240
return nil, nil, errorf("file is empty")
241
} else if err != nil {
242
return nil, nil, errorf("failed to read intro: %w", err)
243
}
244
if line != intro {
245
return nil, nil, errorf("unexpected intro: %q", line)
246
}
247
248
sr := NewStanzaReader(rr)
249
for {
250
peek, err := rr.Peek(len(footerPrefix))
251
if err != nil {
252
return nil, nil, errorf("failed to read header: %w", err)
253
}
254
255
if bytes.Equal(peek, footerPrefix) {
256
line, err := rr.ReadBytes('\n')
257
if err != nil {
258
return nil, nil, fmt.Errorf("failed to read header: %w", err)
259
}
260
261
prefix, args := splitArgs(line)
262
if prefix != string(footerPrefix) || len(args) != 1 {
263
return nil, nil, errorf("malformed closing line: %q", line)
264
}
265
h.MAC, err = DecodeString(args[0])
266
if err != nil || len(h.MAC) != 32 {
267
return nil, nil, errorf("malformed closing line %q: %v", line, err)
268
}
269
break
270 }
271
272
s, err := sr.ReadStanza()
273
if err != nil {
274
return nil, nil, fmt.Errorf("failed to parse header: %w", err)
275
}
276
h.Recipients = append(h.Recipients, s)
277 }
278
279 // If input is a bufio.Reader, rr might be equal to input because
280 // bufio.NewReader short-circuits. In this case we can just return it (and
281 // we would end up reading the buffer twice if we prepended the peek below).
282
if rr == input {
283
return h, rr, nil
284
}
285 // Otherwise, unwind the bufio overread and return the unbuffered input.
286
buf, err := rr.Peek(rr.Buffered())
287
if err != nil {
288
return nil, nil, errorf("internal error: %v", err)
289
}
290
payload := io.MultiReader(bytes.NewReader(buf), input)
291
return h, payload, nil
292 }
293
294
func splitArgs(line []byte) (string, []string) {
295
l := strings.TrimSuffix(string(line), "\n")
296
parts := strings.Split(l, " ")
297
return parts[0], parts[1:]
298
}
299
300
func isValidString(s string) bool {
301
if len(s) == 0 {
302
return false
303
}
304
for _, c := range s {
305
if c < 33 || c > 126 {
306
return false
307
}
308 }
309
return true
310 }
311
filippo.io/age/internal/inspect/inspect.go 15.1%
1 package inspect
2
3 import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "io"
8 "strings"
9
10 "filippo.io/age/armor"
11 "filippo.io/age/internal/format"
12 "filippo.io/age/internal/stream"
13 )
14
15 type Metadata struct {
16 Version string `json:"version"`
17 Postquantum string `json:"postquantum"` // "yes" or "no" or "unknown"
18 Armor bool `json:"armor"`
19 StanzaTypes []string `json:"stanza_types"`
20 Sizes struct {
21 Header int64 `json:"header"`
22 Armor int64 `json:"armor"`
23 Overhead int64 `json:"overhead"`
24 // Currently, we don't do any padding, not MinPayload == MaxPayload and
25 // MinPadding == MaxPadding == 0, but that might change in the future.
26 MinPayload int64 `json:"min_payload"`
27 MaxPayload int64 `json:"max_payload"`
28 MinPadding int64 `json:"min_padding"`
29 MaxPadding int64 `json:"max_padding"`
30 } `json:"sizes"`
31 }
32
33
func Inspect(r io.Reader, fileSize int64) (*Metadata, error) {
34
data := &Metadata{
35
Version: "age-encryption.org/v1",
36
Postquantum: "unknown",
37
}
38
39
tr := &trackReader{r: r}
40
br := bufio.NewReader(tr)
41
const maxWhitespace = 1024
42
start, _ := br.Peek(maxWhitespace + len(armor.Header))
43
if strings.HasPrefix(string(bytes.TrimSpace(start)), armor.Header) {
44
r = armor.NewReader(br)
45
data.Armor = true
46
} else {
47
r = br
48
}
49
50
hdr, rest, err := format.Parse(r)
51
if err != nil {
52
return nil, fmt.Errorf("failed to read header: %w", err)
53
}
54
55
buf := &bytes.Buffer{}
56
if err := hdr.Marshal(buf); err != nil {
57
return nil, fmt.Errorf("failed to re-serialize header: %w", err)
58
}
59
data.Sizes.Header = int64(buf.Len())
60
61
for _, s := range hdr.Recipients {
62
data.StanzaTypes = append(data.StanzaTypes, s.Type)
63
switch s.Type {
64
case "X25519", "ssh-rsa", "ssh-ed25519", "age-encryption.org/p256tag", "piv-p256":
65
data.Postquantum = "no"
66
case "mlkem768x25519", "scrypt", "age-encryption.org/mlkem768p256tag":
67
if data.Postquantum != "no" {
68
data.Postquantum = "yes"
69
}
70 }
71 }
72
73 // If fileSize is not provided, or if it's the size of the armored file
74 // (which can have LF or CRLF line endings, varying its size), read to
75 // the end to determine it.
76
if fileSize == -1 || data.Armor {
77
n, err := io.Copy(io.Discard, rest)
78
if err != nil {
79
return nil, fmt.Errorf("failed to read rest of file: %w", err)
80
}
81
fileSize = data.Sizes.Header + n
82
if !tr.done {
83
panic("trackReader not done after io.Copy")
84 }
85
if tr.count != fileSize && !data.Armor {
86
panic("trackReader count mismatch")
87 }
88
data.Sizes.Armor = tr.count - fileSize
89 }
90
data.Sizes.Overhead, err = streamOverhead(fileSize - data.Sizes.Header)
91
if err != nil {
92
return nil, fmt.Errorf("failed to compute stream overhead: %w", err)
93
}
94
data.Sizes.MinPayload = fileSize - data.Sizes.Header - data.Sizes.Overhead
95
data.Sizes.MaxPayload = data.Sizes.MinPayload
96
return data, nil
97 }
98
99 type trackReader struct {
100 r io.Reader
101 count int64
102 done bool
103 }
104
105
func (tr *trackReader) Read(p []byte) (int, error) {
106
n, err := tr.r.Read(p)
107
tr.count += int64(n)
108
if err == io.EOF {
109
tr.done = true
110
} else if tr.done {
111
panic("non-EOF read after EOF")
112 }
113
return n, err
114 }
115
116
func streamOverhead(payloadSize int64) (int64, error) {
117
const streamNonceSize = 16
118
if payloadSize < streamNonceSize {
119
return 0, fmt.Errorf("encrypted size too small: %d", payloadSize)
120
}
121
encryptedSize := payloadSize - streamNonceSize
122
plaintextSize, err := stream.PlaintextSize(encryptedSize)
123
if err != nil {
124
return 0, err
125
}
126
return payloadSize - plaintextSize, nil
127 }
128
filippo.io/age/internal/stream/stream.go 84.0%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 // Package stream implements a variant of the STREAM chunked encryption scheme.
6 package stream
7
8 import (
9 "bytes"
10 "crypto/cipher"
11 "encoding/binary"
12 "errors"
13 "fmt"
14 "io"
15 "sync/atomic"
16
17 "golang.org/x/crypto/chacha20poly1305"
18 )
19
20 const ChunkSize = 64 * 1024
21
22
func EncryptedChunkCount(encryptedSize int64) (int64, error) {
23
chunks := (encryptedSize + encChunkSize - 1) / encChunkSize
24
25
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
26
expChunks := (plaintextSize + ChunkSize - 1) / ChunkSize
27
// Empty plaintext, the only case that allows (and requires) an empty chunk.
28
if plaintextSize == 0 {
29
expChunks = 1
30
}
31
if expChunks != chunks {
32
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
33
}
34
35
return chunks, nil
36 }
37
38
func PlaintextSize(encryptedSize int64) (int64, error) {
39
chunks, err := EncryptedChunkCount(encryptedSize)
40
if err != nil {
41
return 0, err
42
}
43
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
44
return plaintextSize, nil
45 }
46
47 type DecryptReader struct {
48 a cipher.AEAD
49 src io.Reader
50
51 unread []byte // decrypted but unread data, backed by buf
52 buf [encChunkSize]byte
53
54 err error
55 nonce [chacha20poly1305.NonceSize]byte
56 }
57
58 const (
59 encChunkSize = ChunkSize + chacha20poly1305.Overhead
60 lastChunkFlag = 0x01
61 )
62
63
func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) {
64
aead, err := chacha20poly1305.New(key)
65
if err != nil {
66
return nil, err
67
}
68
return &DecryptReader{a: aead, src: src}, nil
69 }
70
71
func (r *DecryptReader) Read(p []byte) (int, error) {
72
if len(r.unread) > 0 {
73
n := copy(p, r.unread)
74
r.unread = r.unread[n:]
75
return n, nil
76
}
77
if r.err != nil {
78
return 0, r.err
79
}
80
if len(p) == 0 {
81
return 0, nil
82
}
83
84
last, err := r.readChunk()
85
if err != nil {
86
r.err = err
87
return 0, err
88
}
89
90
n := copy(p, r.unread)
91
r.unread = r.unread[n:]
92
93
if last {
94
// Ensure there is an EOF after the last chunk as expected. In other
95
// words, check for trailing data after a full-length final chunk.
96
// Hopefully, the underlying reader supports returning EOF even if it
97
// had previously returned an EOF to ReadFull.
98
if _, err := r.src.Read(make([]byte, 1)); err == nil {
99
r.err = errors.New("trailing data after end of encrypted file")
100
} else if err != io.EOF {
101
r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)
102
} else {
103
r.err = io.EOF
104
}
105 }
106
107
return n, nil
108 }
109
110 // readChunk reads the next chunk of ciphertext from r.src and makes it available
111 // in r.unread. last is true if the chunk was marked as the end of the message.
112 // readChunk must not be called again after returning a last chunk or an error.
113
func (r *DecryptReader) readChunk() (last bool, err error) {
114
if len(r.unread) != 0 {
115
panic("stream: internal error: readChunk called with dirty buffer")
116 }
117
118
in := r.buf[:]
119
n, err := io.ReadFull(r.src, in)
120
switch {
121
case err == io.EOF:
122
// A message can't end without a marked chunk. This message is truncated.
123
return false, io.ErrUnexpectedEOF
124
case err == io.ErrUnexpectedEOF:
125
// The last chunk can be short, but not empty unless it's the first and
126
// only chunk.
127
if !nonceIsZero(&r.nonce) && n == r.a.Overhead() {
128
return false, errors.New("last chunk is empty, try age v1.0.0, and please consider reporting this")
129
}
130
in = in[:n]
131
last = true
132
setLastChunkFlag(&r.nonce)
133
case err != nil:
134
return false, err
135 }
136
137
outBuf := make([]byte, 0, ChunkSize)
138
out, err := r.a.Open(outBuf, r.nonce[:], in, nil)
139
if err != nil && !last {
140
// Check if this was a full-length final chunk.
141
last = true
142
setLastChunkFlag(&r.nonce)
143
out, err = r.a.Open(outBuf, r.nonce[:], in, nil)
144
}
145
if err != nil {
146
return false, errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")
147
}
148
149
incNonce(&r.nonce)
150
r.unread = r.buf[:copy(r.buf[:], out)]
151
return last, nil
152 }
153
154
func incNonce(nonce *[chacha20poly1305.NonceSize]byte) {
155
for i := len(nonce) - 2; i >= 0; i-- {
156
nonce[i]++
157
if nonce[i] != 0 {
158
return
159
}
160 }
161 // The counter is 88 bits, this is unreachable.
162
panic("stream: chunk counter wrapped around")
163 }
164
165
func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte {
166
var nonce [chacha20poly1305.NonceSize]byte
167
binary.BigEndian.PutUint64(nonce[3:11], uint64(chunkIndex))
168
return &nonce
169
}
170
171
func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
172
nonce[len(nonce)-1] = lastChunkFlag
173
}
174
175
func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool {
176
return *nonce == [chacha20poly1305.NonceSize]byte{}
177
}
178
179 type EncryptWriter struct {
180 a cipher.AEAD
181 dst io.Writer
182 buf bytes.Buffer
183 nonce [chacha20poly1305.NonceSize]byte
184 err error
185 }
186
187
func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) {
188
aead, err := chacha20poly1305.New(key)
189
if err != nil {
190
return nil, err
191
}
192
return &EncryptWriter{a: aead, dst: dst}, nil
193 }
194
195
func (w *EncryptWriter) Write(p []byte) (n int, err error) {
196
if w.err != nil {
197
return 0, w.err
198
}
199
if len(p) == 0 {
200
return 0, nil
201
}
202
203
total := len(p)
204
for len(p) > 0 {
205
n := min(len(p), ChunkSize-w.buf.Len())
206
w.buf.Write(p[:n])
207
p = p[n:]
208
209
// Only flush if there's a full chunk with bytes still to write, or we
210
// can't know if this is the last chunk yet.
211
if w.buf.Len() == ChunkSize && len(p) > 0 {
212
if err := w.flushChunk(notLastChunk); err != nil {
213
w.err = err
214
return 0, err
215
}
216 }
217 }
218
return total, nil
219 }
220
221 // Close flushes the last chunk. It does not close the underlying Writer.
222
func (w *EncryptWriter) Close() error {
223
if w.err != nil {
224
return w.err
225
}
226
227
w.err = w.flushChunk(lastChunk)
228
if w.err != nil {
229
return w.err
230
}
231
232
w.err = errors.New("stream.Writer is already closed")
233
return nil
234 }
235
236 const (
237 lastChunk = true
238 notLastChunk = false
239 )
240
241
func (w *EncryptWriter) flushChunk(last bool) error {
242
if !last && w.buf.Len() != ChunkSize {
243
panic("stream: internal error: flush called with partial chunk")
244 }
245
246
if last {
247
setLastChunkFlag(&w.nonce)
248
}
249
w.buf.Grow(chacha20poly1305.Overhead)
250
ciphertext := w.a.Seal(w.buf.Bytes()[:0], w.nonce[:], w.buf.Bytes(), nil)
251
_, err := w.dst.Write(ciphertext)
252
incNonce(&w.nonce)
253
w.buf.Reset()
254
return err
255 }
256
257 type EncryptReader struct {
258 a cipher.AEAD
259 src io.Reader
260
261 // The first ready bytes of buf are already encrypted. This may be less than
262 // buf.Len(), because we need to over-read to know if a chunk is the last.
263 ready int
264 buf bytes.Buffer
265
266 nonce [chacha20poly1305.NonceSize]byte
267 err error
268 }
269
270
func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) {
271
aead, err := chacha20poly1305.New(key)
272
if err != nil {
273
return nil, err
274
}
275
return &EncryptReader{a: aead, src: src}, nil
276 }
277
278
func (r *EncryptReader) Read(p []byte) (int, error) {
279
if r.ready > 0 {
280
n, err := r.buf.Read(p[:min(len(p), r.ready)])
281
r.ready -= n
282
return n, err
283
}
284
if r.err != nil {
285
return 0, r.err
286
}
287
if len(p) == 0 {
288
return 0, nil
289
}
290
291
if err := r.feedBuffer(); err != nil {
292
r.err = err
293
return 0, err
294
}
295
296
n, err := r.buf.Read(p[:min(len(p), r.ready)])
297
r.ready -= n
298
return n, err
299 }
300
301 // feedBuffer reads and encrypts the next chunk from r.src and appends it to
302 // r.buf. It sets r.ready to the number of newly available bytes in r.buf.
303
func (r *EncryptReader) feedBuffer() error {
304
if r.ready > 0 {
305
panic("stream: internal error: feedBuffer called with dirty buffer")
306 }
307
308 // CopyN will use r.buf.ReadFrom/WriteTo to fill the buffer directly.
309 // We need ChunkSize + 1 bytes to determine if this is the last chunk.
310
_, err := io.CopyN(&r.buf, r.src, int64(ChunkSize-r.buf.Len()+1))
311
if err != nil && err != io.EOF {
312
return err
313
}
314
315
if last := r.buf.Len() <= ChunkSize; last {
316
setLastChunkFlag(&r.nonce)
317
318
// After Grow, we know r.buf.Bytes() has enough capacity for the
319
// overhead. We encrypt in place and then do a Write to include the
320
// overhead in the buffer.
321
r.buf.Grow(chacha20poly1305.Overhead)
322
plaintext := r.buf.Bytes()
323
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
324
incNonce(&r.nonce)
325
r.buf.Write(plaintext[len(plaintext) : len(plaintext)+chacha20poly1305.Overhead])
326
r.ready = r.buf.Len()
327
328
r.err = io.EOF
329
return nil
330
}
331
332 // Same, but accounting for the tail byte which will remain unencrypted and
333 // needs to be shifted past the overhead.
334
if r.buf.Len() != ChunkSize+1 {
335
panic("stream: internal error: unexpected buffer length")
336 }
337
tailByte := r.buf.Bytes()[ChunkSize]
338
r.buf.Grow(chacha20poly1305.Overhead)
339
plaintext := r.buf.Bytes()[:ChunkSize]
340
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
341
incNonce(&r.nonce)
342
r.buf.Write(plaintext[len(plaintext)+1 : len(plaintext)+chacha20poly1305.Overhead])
343
r.buf.WriteByte(tailByte)
344
r.ready = ChunkSize + chacha20poly1305.Overhead
345
346
return nil
347 }
348
349 type DecryptReaderAt struct {
350 a cipher.AEAD
351 src io.ReaderAt
352 size int64
353 chunks int64
354 cache atomic.Pointer[cachedChunk]
355 }
356
357 type cachedChunk struct {
358 off int64
359 data []byte
360 }
361
362
func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
363
aead, err := chacha20poly1305.New(key)
364
if err != nil {
365
return nil, err
366
}
367
368 // Check that size is valid by decrypting the final chunk.
369
chunks, err := EncryptedChunkCount(size)
370
if err != nil {
371
return nil, err
372
}
373
finalChunkIndex := chunks - 1
374
finalChunkOff := finalChunkIndex * encChunkSize
375
finalChunkSize := size - finalChunkOff
376
finalChunk := make([]byte, finalChunkSize)
377
if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil {
378
return nil, fmt.Errorf("failed to read final chunk: %w", err)
379
}
380
nonce := nonceForChunk(finalChunkIndex)
381
setLastChunkFlag(nonce)
382
plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil)
383
if err != nil {
384
return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err)
385
}
386
cache := &cachedChunk{off: finalChunkOff, data: plaintext}
387
388
plaintextSize := size - chunks*chacha20poly1305.Overhead
389
r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks}
390
r.cache.Store(cache)
391
return r, nil
392 }
393
394
func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
395
if off < 0 || off > r.size {
396
return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off)
397
}
398
if len(p) == 0 {
399
return 0, nil
400
}
401
var cacheUpdate *cachedChunk
402
chunk := make([]byte, encChunkSize)
403
for len(p) > 0 && off < r.size {
404
chunkIndex := off / ChunkSize
405
chunkOff := chunkIndex * encChunkSize
406
encSize := r.size + r.chunks*chacha20poly1305.Overhead
407
chunkSize := min(encSize-chunkOff, encChunkSize)
408
409
cached := r.cache.Load()
410
var plaintext []byte
411
if cached != nil && cached.off == chunkOff {
412
plaintext = cached.data
413
cacheUpdate = nil
414
} else {
415
nn, err := r.src.ReadAt(chunk[:chunkSize], chunkOff)
416
if err == io.EOF {
417
if int64(nn) != chunkSize {
418
err = io.ErrUnexpectedEOF
419
} else {
420
err = nil
421
}
422 }
423
if err != nil {
424
return n, fmt.Errorf("failed to read chunk at offset %d: %w", chunkOff, err)
425
}
426
nonce := nonceForChunk(chunkIndex)
427
if chunkIndex == r.chunks-1 {
428
setLastChunkFlag(nonce)
429
}
430
plaintext, err = r.a.Open(chunk[:0], nonce[:], chunk[:chunkSize], nil)
431
if err != nil {
432
return n, fmt.Errorf("failed to decrypt and authenticate chunk at offset %d: %w", chunkOff, err)
433
}
434
cacheUpdate = &cachedChunk{off: chunkOff, data: plaintext}
435 }
436
437
plainChunkOff := int(off - chunkIndex*ChunkSize)
438
copySize := min(len(plaintext)-plainChunkOff, len(p))
439
copy(p, plaintext[plainChunkOff:plainChunkOff+copySize])
440
p = p[copySize:]
441
off += int64(copySize)
442
n += copySize
443 }
444
if cacheUpdate != nil {
445
r.cache.Store(cacheUpdate)
446
}
447
if off == r.size {
448
return n, io.EOF
449
}
450
return n, nil
451 }
452
filippo.io/age/internal/term/term.go 0.0%
1 package term
2
3 import (
4 "fmt"
5 "os"
6 "runtime"
7
8 "golang.org/x/term"
9 )
10
11 // enableVirtualTerminalProcessing tries to enable virtual terminal processing
12 // on Windows. If it fails, avoid using escape sequences to prevent weird
13 // characters being printed to the console.
14 var enableVirtualTerminalProcessing func(out *os.File) error
15
16 // clearLine clears the current line on the terminal, or opens a new line if
17 // terminal escape codes don't work.
18
func clearLine(out *os.File) {
19
const (
20
CUI = "\033[" // Control Sequence Introducer
21
CPL = CUI + "F" // Cursor Previous Line
22
EL = CUI + "K" // Erase in Line
23
)
24
25
// First, open a new line, which is guaranteed to work everywhere. Then, try
26
// to erase the line above with escape codes, if possible.
27
//
28
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
29
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
30
// cursor would not go back to the start of the line with a simple LF.
31
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
32
fmt.Fprintf(out, "\r\n")
33
if enableVirtualTerminalProcessing == nil || enableVirtualTerminalProcessing(out) == nil {
34
fmt.Fprintf(out, CPL+EL)
35
}
36 }
37
38 // WithTerminal runs f with the terminal input and output files, if available.
39 // WithTerminal does not open a non-terminal stdin, so the caller does not need
40 // to check if stdin is in use.
41
func WithTerminal(f func(in, out *os.File) error) error {
42
if runtime.GOOS == "windows" {
43
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
44
if err != nil {
45
return err
46
}
47
defer in.Close()
48
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
49
if err != nil {
50
return err
51
}
52
defer out.Close()
53
return f(in, out)
54
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
55
defer tty.Close()
56
return f(tty, tty)
57
} else if term.IsTerminal(int(os.Stdin.Fd())) {
58
return f(os.Stdin, os.Stdin)
59
} else {
60
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
61
}
62 }
63
64 // ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral.
65
func ReadSecret(prompt string) (s []byte, err error) {
66
err = WithTerminal(func(in, out *os.File) error {
67
fmt.Fprintf(out, "%s ", prompt)
68
defer clearLine(out)
69
s, err = term.ReadPassword(int(in.Fd()))
70
return err
71
})
72
return
73 }
74
75 // ReadPublic reads a value from the terminal. The prompt is ephemeral.
76
func ReadPublic(prompt string) (s []byte, err error) {
77
err = WithTerminal(func(in, out *os.File) error {
78
fmt.Fprintf(out, "%s ", prompt)
79
defer clearLine(out)
80
81
oldState, err := term.MakeRaw(int(in.Fd()))
82
if err != nil {
83
return err
84
}
85
defer term.Restore(int(in.Fd()), oldState)
86
87
t := term.NewTerminal(in, "")
88
line, err := t.ReadLine()
89
s = []byte(line)
90
return err
91 })
92
return
93 }
94
95 // ReadCharacter reads a single character from the terminal with no echo. The
96 // prompt is ephemeral.
97
func ReadCharacter(prompt string) (c byte, err error) {
98
err = WithTerminal(func(in, out *os.File) error {
99
fmt.Fprintf(out, "%s ", prompt)
100
defer clearLine(out)
101
102
oldState, err := term.MakeRaw(int(in.Fd()))
103
if err != nil {
104
return err
105
}
106
defer term.Restore(int(in.Fd()), oldState)
107
108
b := make([]byte, 1)
109
if _, err := in.Read(b); err != nil {
110
return err
111
}
112
113
c = b[0]
114
return nil
115 })
116
return
117 }
118
119 // IsTerminal returns whether the given file is a terminal.
120
func IsTerminal(f *os.File) bool {
121
return term.IsTerminal(int(f.Fd()))
122
}
123
filippo.io/age/parse.go 41.7%
1 // Copyright 2021 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package age
6
7 import (
8 "bufio"
9 "fmt"
10 "io"
11 "strings"
12 "unicode/utf8"
13 )
14
15 // ParseIdentities parses a file with one or more private key encodings, one per
16 // line. Empty lines and lines starting with "#" are ignored.
17 //
18 // This is the same syntax as the private key files accepted by the CLI, except
19 // the CLI also accepts SSH private keys, which are not recommended for the
20 // average application, and plugins, which involve invoking external programs.
21 //
22 // Currently, all returned values are of type *[X25519Identity] or
23 // *[HybridIdentity], but different types might be returned in the future.
24
func ParseIdentities(f io.Reader) ([]Identity, error) {
25
const privateKeySizeLimit = 1 << 24 // 16 MiB
26
var ids []Identity
27
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
28
var n int
29
for scanner.Scan() {
30
n++
31
line := scanner.Text()
32
if strings.HasPrefix(line, "#") || line == "" {
33
continue
34 }
35
if !utf8.ValidString(line) {
36
return nil, fmt.Errorf("identities file is not valid UTF-8")
37
}
38
i, err := parseIdentity(line)
39
if err != nil {
40
return nil, fmt.Errorf("error at line %d: %v", n, err)
41
}
42
ids = append(ids, i)
43 }
44
if err := scanner.Err(); err != nil {
45
return nil, fmt.Errorf("failed to read identities file: %v", err)
46
}
47
if len(ids) == 0 {
48
return nil, fmt.Errorf("no identities found")
49
}
50
return ids, nil
51 }
52
53
func parseIdentity(arg string) (Identity, error) {
54
switch {
55
case strings.HasPrefix(arg, "AGE-SECRET-KEY-1"):
56
return ParseX25519Identity(arg)
57
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
58
return ParseHybridIdentity(arg)
59
default:
60
return nil, fmt.Errorf("unknown identity type: %q", arg)
61 }
62 }
63
64 // ParseRecipients parses a file with one or more public key encodings, one per
65 // line. Empty lines and lines starting with "#" are ignored.
66 //
67 // This is the same syntax as the recipients files accepted by the CLI, except
68 // the CLI also accepts SSH recipients, which are not recommended for the
69 // average application, tagged recipients, which have different privacy
70 // properties, and plugins, which involve invoking external programs.
71 //
72 // Currently, all returned values are of type *[X25519Recipient] or
73 // *[HybridRecipient] but different types might be returned in the future.
74
func ParseRecipients(f io.Reader) ([]Recipient, error) {
75
const recipientFileSizeLimit = 1 << 24 // 16 MiB
76
var recs []Recipient
77
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
78
var n int
79
for scanner.Scan() {
80
n++
81
line := scanner.Text()
82
if strings.HasPrefix(line, "#") || line == "" {
83
continue
84 }
85
if !utf8.ValidString(line) {
86
return nil, fmt.Errorf("recipients file is not valid UTF-8")
87
}
88
r, err := parseRecipient(line)
89
if err != nil {
90
return nil, fmt.Errorf("error at line %d: %v", n, err)
91
}
92
recs = append(recs, r)
93 }
94
if err := scanner.Err(); err != nil {
95
return nil, fmt.Errorf("failed to read recipients file: %v", err)
96
}
97
if len(recs) == 0 {
98
return nil, fmt.Errorf("no recipients found")
99
}
100
return recs, nil
101 }
102
103
func parseRecipient(arg string) (Recipient, error) {
104
switch {
105
case strings.HasPrefix(arg, "age1pq1"):
106
return ParseHybridRecipient(arg)
107
case strings.HasPrefix(arg, "age1"):
108
return ParseX25519Recipient(arg)
109
default:
110
return nil, fmt.Errorf("unknown recipient type: %q", arg)
111 }
112 }
113
filippo.io/age/plugin/client.go 51.0%
1 // Copyright 2021 Google LLC
2 //
3 // Use of this source code is governed by a BSD-style
4 // license that can be found in the LICENSE file or at
5 // https://developers.google.com/open-source/licenses/bsd
6
7 package plugin
8
9 import (
10 "bufio"
11 "crypto/rand"
12 "errors"
13 "fmt"
14 "io"
15 mathrand "math/rand/v2"
16 "os"
17 "path/filepath"
18 "strconv"
19 "strings"
20 "time"
21
22 exec "golang.org/x/sys/execabs"
23
24 "filippo.io/age"
25 "filippo.io/age/internal/format"
26 )
27
28 type Recipient struct {
29 name string
30 encoding string
31 ui *ClientUI
32
33 // identity is true when encoding is an identity string.
34 identity bool
35 }
36
37 var _ age.Recipient = &Recipient{}
38 var _ age.RecipientWithLabels = &Recipient{}
39
40
func NewRecipient(s string, ui *ClientUI) (*Recipient, error) {
41
name, _, err := ParseRecipient(s)
42
if err != nil {
43
return nil, err
44
}
45
return &Recipient{
46
name: name, encoding: s, ui: ui,
47
}, nil
48 }
49
50 // Name returns the plugin name, which is used in the recipient ("age1name1...")
51 // and identity ("AGE-PLUGIN-NAME-1...") encodings, as well as in the plugin
52 // binary name ("age-plugin-name").
53
func (r *Recipient) Name() string {
54
return r.name
55
}
56
57 // String returns the recipient encoding string ("age1name1...") or
58 // "<identity-based recipient>" if r was created by [Identity.Recipient].
59
func (r *Recipient) String() string {
60
if r.identity {
61
return "<identity-based recipient>"
62
}
63
return r.encoding
64 }
65
66
func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
67
stanzas, _, err = r.WrapWithLabels(fileKey)
68
return
69
}
70
71
func (r *Recipient) WrapWithLabels(fileKey []byte) (stanzas []*age.Stanza, labels []string, err error) {
72
defer func() {
73
if err != nil {
74
err = fmt.Errorf("%s plugin: %w", r.name, err)
75
}
76 }()
77
78
conn, err := openClientConnection(r.name, "recipient-v1")
79
if err != nil {
80
return nil, nil, fmt.Errorf("couldn't start plugin: %w", err)
81
}
82
defer conn.Close()
83
84
// Phase 1: client sends recipient or identity and file key
85
addType := "add-recipient"
86
if r.identity {
87
addType = "add-identity"
88
}
89
if err := writeStanza(conn, addType, r.encoding); err != nil {
90
return nil, nil, err
91
}
92
if _, err := writeGrease(conn); err != nil {
93
return nil, nil, err
94
}
95
if err := writeStanzaWithBody(conn, "wrap-file-key", fileKey); err != nil {
96
return nil, nil, err
97
}
98
if err := writeStanza(conn, "extension-labels"); err != nil {
99
return nil, nil, err
100
}
101
if err := writeStanza(conn, "done"); err != nil {
102
return nil, nil, err
103
}
104
105 // Phase 2: plugin responds with stanzas
106
sr := format.NewStanzaReader(bufio.NewReader(conn))
107
ReadLoop:
108
for {
109
s, err := r.ui.readStanza(r.name, sr)
110
if err != nil {
111
return nil, nil, err
112
}
113
114
switch s.Type {
115
case "recipient-stanza":
116
if len(s.Args) < 2 {
117
return nil, nil, fmt.Errorf("malformed recipient stanza: unexpected argument count")
118
}
119
n, err := strconv.Atoi(s.Args[0])
120
if err != nil {
121
return nil, nil, fmt.Errorf("malformed recipient stanza: invalid index")
122
}
123 // We only send a single file key, so the index must be 0.
124
if n != 0 {
125
return nil, nil, fmt.Errorf("malformed recipient stanza: unexpected index")
126
}
127
128
stanzas = append(stanzas, &age.Stanza{
129
Type: s.Args[1],
130
Args: s.Args[2:],
131
Body: s.Body,
132
})
133
134
if err := writeStanza(conn, "ok"); err != nil {
135
return nil, nil, err
136
}
137
case "labels":
138
if labels != nil {
139
return nil, nil, fmt.Errorf("repeated labels stanza")
140
}
141
labels = s.Args
142
143
if err := writeStanza(conn, "ok"); err != nil {
144
return nil, nil, err
145
}
146
case "error":
147
if err := writeStanza(conn, "ok"); err != nil {
148
return nil, nil, err
149
}
150
151
return nil, nil, fmt.Errorf("%s", s.Body)
152
case "done":
153
break ReadLoop
154
default:
155
if ok, err := r.ui.handle(r.name, conn, s); err != nil {
156
return nil, nil, err
157
} else if !ok {
158
if err := writeStanza(conn, "unsupported"); err != nil {
159
return nil, nil, err
160
}
161 }
162 }
163 }
164
165
if len(stanzas) == 0 {
166
return nil, nil, fmt.Errorf("received zero recipient stanzas")
167
}
168
169
return stanzas, labels, nil
170 }
171
172 type Identity struct {
173 name string
174 encoding string
175 ui *ClientUI
176 }
177
178 var _ age.Identity = &Identity{}
179
180
func NewIdentity(s string, ui *ClientUI) (*Identity, error) {
181
name, _, err := ParseIdentity(s)
182
if err != nil {
183
return nil, err
184
}
185
return &Identity{
186
name: name, encoding: s, ui: ui,
187
}, nil
188 }
189
190
func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) {
191
s := EncodeIdentity(name, nil)
192
if s == "" {
193
return nil, fmt.Errorf("invalid plugin name: %q", name)
194
}
195
return &Identity{
196
name: name, encoding: s, ui: ui,
197
}, nil
198 }
199
200 // Name returns the plugin name, which is used in the recipient ("age1name1...")
201 // and identity ("AGE-PLUGIN-NAME-1...") encodings, as well as in the plugin
202 // binary name ("age-plugin-name").
203
func (i *Identity) Name() string {
204
return i.name
205
}
206
207 // String returns the identity encoding string ("AGE-PLUGIN-NAME-1...").
208
func (i *Identity) String() string {
209
return i.encoding
210
}
211
212 // Recipient returns a Recipient wrapping this identity. When that Recipient is
213 // used to encrypt a file key, the identity encoding is provided as-is to the
214 // plugin, which is expected to support encrypting to identities.
215
func (i *Identity) Recipient() *Recipient {
216
return &Recipient{
217
name: i.name,
218
encoding: i.encoding,
219
identity: true,
220
ui: i.ui,
221
}
222
}
223
224
func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
225
defer func() {
226
if err != nil {
227
err = fmt.Errorf("%s plugin: %w", i.name, err)
228
}
229 }()
230
231
conn, err := openClientConnection(i.name, "identity-v1")
232
if err != nil {
233
return nil, fmt.Errorf("couldn't start plugin: %w", err)
234
}
235
defer conn.Close()
236
237
// Phase 1: client sends the plugin the identity string and the stanzas
238
if err := writeStanza(conn, "add-identity", i.encoding); err != nil {
239
return nil, err
240
}
241
if _, err := writeGrease(conn); err != nil {
242
return nil, err
243
}
244
for _, rs := range stanzas {
245
s := &format.Stanza{
246
Type: "recipient-stanza",
247
Args: append([]string{"0", rs.Type}, rs.Args...),
248
Body: rs.Body,
249
}
250
if err := s.Marshal(conn); err != nil {
251
return nil, err
252
}
253 }
254
if err := writeStanza(conn, "done"); err != nil {
255
return nil, err
256
}
257
258 // Phase 2: plugin responds with various commands and a file key
259
sr := format.NewStanzaReader(bufio.NewReader(conn))
260
ReadLoop:
261
for {
262
s, err := i.ui.readStanza(i.name, sr)
263
if err != nil {
264
return nil, err
265
}
266
267
switch s.Type {
268
case "file-key":
269
if len(s.Args) != 1 {
270
return nil, fmt.Errorf("malformed file-key stanza: unexpected arguments count")
271
}
272
n, err := strconv.Atoi(s.Args[0])
273
if err != nil {
274
return nil, fmt.Errorf("malformed file-key stanza: invalid index")
275
}
276 // We only send a single file key, so the index must be 0.
277
if n != 0 {
278
return nil, fmt.Errorf("malformed file-key stanza: unexpected index")
279
}
280
if fileKey != nil {
281
return nil, fmt.Errorf("received duplicated file-key stanza")
282
}
283
284
fileKey = s.Body
285
286
if err := writeStanza(conn, "ok"); err != nil {
287
return nil, err
288
}
289
case "error":
290
if err := writeStanza(conn, "ok"); err != nil {
291
return nil, err
292
}
293
294
return nil, fmt.Errorf("%s", s.Body)
295
case "done":
296
break ReadLoop
297
default:
298
if ok, err := i.ui.handle(i.name, conn, s); err != nil {
299
return nil, err
300
} else if !ok {
301
if err := writeStanza(conn, "unsupported"); err != nil {
302
return nil, err
303
}
304 }
305 }
306 }
307
308
if fileKey == nil {
309
return nil, age.ErrIncorrectIdentity
310
}
311
return fileKey, nil
312 }
313
314 // ClientUI holds callbacks that will be invoked by (Un)Wrap if the plugin
315 // wishes to interact with the user. If any of them is nil or returns an error,
316 // failure will be reported to the plugin, but note that the error is otherwise
317 // discarded. Implementations are encouraged to display errors to the user
318 // before returning them.
319 type ClientUI struct {
320 // DisplayMessage displays the message, which is expected to have lowercase
321 // initials and no final period.
322 DisplayMessage func(name, message string) error
323
324 // RequestValue requests a secret or public input, with the provided prompt.
325 RequestValue func(name, prompt string, secret bool) (string, error)
326
327 // Confirm requests a confirmation with the provided prompt. The yes and no
328 // value are the choices provided to the user. no may be empty. The return
329 // value indicates whether the user selected the yes or no option.
330 Confirm func(name, prompt, yes, no string) (choseYes bool, err error)
331
332 // WaitTimer is invoked once (Un)Wrap has been waiting for 5 seconds on the
333 // plugin, for example because the plugin is waiting for an external event
334 // (e.g. a hardware token touch). Unlike the other callbacks, WaitTimer runs
335 // in a separate goroutine, and if missing it's simply ignored.
336 WaitTimer func(name string)
337 }
338
339
func (c *ClientUI) handle(name string, conn *clientConnection, s *format.Stanza) (ok bool, err error) {
340
switch s.Type {
341
case "msg":
342
if c.DisplayMessage == nil {
343
return true, writeStanza(conn, "fail")
344
}
345
if err := c.DisplayMessage(name, string(s.Body)); err != nil {
346
return true, writeStanza(conn, "fail")
347
}
348
return true, writeStanza(conn, "ok")
349
case "request-secret", "request-public":
350
if c.RequestValue == nil {
351
return true, writeStanza(conn, "fail")
352
}
353
secret, err := c.RequestValue(name, string(s.Body), s.Type == "request-secret")
354
if err != nil {
355
return true, writeStanza(conn, "fail")
356
}
357
return true, writeStanzaWithBody(conn, "ok", []byte(secret))
358
case "confirm":
359
if len(s.Args) != 1 && len(s.Args) != 2 {
360
return true, fmt.Errorf("malformed confirm stanza: unexpected number of arguments")
361
}
362
if c.Confirm == nil {
363
return true, writeStanza(conn, "fail")
364
}
365
yes, err := format.DecodeString(s.Args[0])
366
if err != nil {
367
return true, fmt.Errorf("malformed confirm stanza: invalid YES option encoding")
368
}
369
var no []byte
370
if len(s.Args) == 2 {
371
no, err = format.DecodeString(s.Args[1])
372
if err != nil {
373
return true, fmt.Errorf("malformed confirm stanza: invalid NO option encoding")
374
}
375 }
376
choseYes, err := c.Confirm(name, string(s.Body), string(yes), string(no))
377
if err != nil {
378
return true, writeStanza(conn, "fail")
379
}
380
result := "yes"
381
if !choseYes {
382
result = "no"
383
}
384
return true, writeStanza(conn, "ok", result)
385
default:
386
return false, nil
387 }
388 }
389
390 // readStanza calls r.ReadStanza and, if set, invokes WaitTimer in a separate
391 // goroutine if the call takes longer than 5 seconds.
392
func (c *ClientUI) readStanza(name string, r *format.StanzaReader) (*format.Stanza, error) {
393
if c.WaitTimer != nil {
394
defer time.AfterFunc(5*time.Second, func() { c.WaitTimer(name) }).Stop()
395 }
396
return r.ReadStanza()
397 }
398
399 type clientConnection struct {
400 cmd *exec.Cmd
401 io.Reader // stdout
402 io.Writer // stdin
403 close func()
404 }
405
406 // NotFoundError is returned by [Recipient.Wrap] and [Identity.Unwrap] when the
407 // plugin binary cannot be found.
408 type NotFoundError struct {
409 // Name is the plugin (not binary) name.
410 Name string
411 // Err is the underlying error, usually an [exec.Error] wrapping
412 // [exec.ErrNotFound].
413 Err error
414 }
415
416
func (e *NotFoundError) Error() string {
417
return fmt.Sprintf("%q plugin not found: %v", e.Name, e.Err)
418
}
419
420
func (e *NotFoundError) Unwrap() error {
421
return e.Err
422
}
423
424 var testOnlyPluginPath string
425
426
func openClientConnection(name, protocol string) (*clientConnection, error) {
427
path := "age-plugin-" + name
428
if testOnlyPluginPath != "" {
429
path = filepath.Join(testOnlyPluginPath, path)
430
} else if strings.ContainsRune(name, os.PathSeparator) {
431
return nil, fmt.Errorf("invalid plugin name: %q", name)
432
}
433
cmd := exec.Command(path, "--age-plugin="+protocol)
434
435
stdout, err := cmd.StdoutPipe()
436
if err != nil {
437
return nil, err
438
}
439
stdin, err := cmd.StdinPipe()
440
if err != nil {
441
return nil, err
442
}
443
444
cc := &clientConnection{
445
cmd: cmd,
446
Reader: stdout,
447
Writer: stdin,
448
close: func() {
449
stdin.Close()
450
stdout.Close()
451
},
452 }
453
454
if os.Getenv("AGEDEBUG") == "plugin" {
455
cc.Reader = io.TeeReader(cc.Reader, os.Stderr)
456
cc.Writer = io.MultiWriter(cc.Writer, os.Stderr)
457
cmd.Stderr = os.Stderr
458
}
459
460 // We don't want the plugins to rely on the working directory for anything
461 // as different clients might treat it differently, so we set it to an empty
462 // temporary directory.
463
cmd.Dir = os.TempDir()
464
465
if err := cmd.Start(); err != nil {
466
if errors.Is(err, exec.ErrNotFound) {
467
return nil, &NotFoundError{Name: name, Err: err}
468
}
469
return nil, err
470 }
471
472
return cc, nil
473 }
474
475
func (cc *clientConnection) Close() error {
476
// Close stdin and stdout and send SIGINT (if supported) to the plugin,
477
// then wait for it to cleanup and exit.
478
cc.close()
479
cc.cmd.Process.Signal(os.Interrupt)
480
return cc.cmd.Wait()
481
}
482
483
func writeStanza(conn io.Writer, t string, args ...string) error {
484
s := &format.Stanza{Type: t, Args: args}
485
return s.Marshal(conn)
486
}
487
488
func writeStanzaWithBody(conn io.Writer, t string, body []byte) error {
489
s := &format.Stanza{Type: t, Body: body}
490
return s.Marshal(conn)
491
}
492
493
func writeGrease(conn io.Writer) (sent bool, err error) {
494
if mathrand.IntN(3) == 0 {
495
return false, nil
496
}
497
s := &format.Stanza{Type: fmt.Sprintf("grease-%x", mathrand.Int())}
498
for i := 0; i < mathrand.IntN(3); i++ {
499
s.Args = append(s.Args, fmt.Sprintf("%d", mathrand.IntN(100)))
500
}
501
if mathrand.IntN(2) == 0 {
502
s.Body = make([]byte, mathrand.IntN(100))
503
rand.Read(s.Body)
504
}
505
return true, s.Marshal(conn)
506 }
507
filippo.io/age/plugin/encode.go 55.8%
1 // Copyright 2023 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package plugin
6
7 import (
8 "crypto/ecdh"
9 "crypto/mlkem"
10 "fmt"
11 "strings"
12
13 "filippo.io/age/internal/bech32"
14 "filippo.io/hpke"
15 )
16
17 // EncodeIdentity encodes a plugin identity string for a plugin with the given
18 // name. If the name is invalid, it returns an empty string.
19
func EncodeIdentity(name string, data []byte) string {
20
if !validPluginName(name) {
21
return ""
22
}
23
s, _ := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", data)
24
return s
25 }
26
27 // ParseIdentity decodes a plugin identity string. It returns the plugin name
28 // in lowercase and the encoded data.
29
func ParseIdentity(s string) (name string, data []byte, err error) {
30
hrp, data, err := bech32.Decode(s)
31
if err != nil {
32
return "", nil, fmt.Errorf("invalid identity encoding: %v", err)
33
}
34
if !strings.HasPrefix(hrp, "AGE-PLUGIN-") || !strings.HasSuffix(hrp, "-") {
35
return "", nil, fmt.Errorf("not a plugin identity: %v", err)
36
}
37
name = strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-")
38
name = strings.ToLower(name)
39
if !validPluginName(name) {
40
return "", nil, fmt.Errorf("invalid plugin name: %q", name)
41
}
42
return name, data, nil
43 }
44
45 // EncodeRecipient encodes a plugin recipient string for a plugin with the given
46 // name. If the name is invalid, it returns an empty string.
47
func EncodeRecipient(name string, data []byte) string {
48
if !validPluginName(name) {
49
return ""
50
}
51
s, _ := bech32.Encode("age1"+strings.ToLower(name), data)
52
return s
53 }
54
55 // ParseRecipient decodes a plugin recipient string. It returns the plugin name
56 // in lowercase and the encoded data.
57
func ParseRecipient(s string) (name string, data []byte, err error) {
58
hrp, data, err := bech32.Decode(s)
59
if err != nil {
60
return "", nil, fmt.Errorf("invalid recipient encoding: %v", err)
61
}
62
if !strings.HasPrefix(hrp, "age1") {
63
return "", nil, fmt.Errorf("not a plugin recipient: %v", err)
64
}
65
name = strings.TrimPrefix(hrp, "age1")
66
if !validPluginName(name) {
67
return "", nil, fmt.Errorf("invalid plugin name: %q", name)
68
}
69
return name, data, nil
70 }
71
72
func validPluginName(name string) bool {
73
if name == "" {
74
return false
75
}
76
allowed := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-._"
77
for _, r := range name {
78
if !strings.ContainsRune(allowed, r) {
79
return false
80
}
81 }
82
return true
83 }
84
85 // EncodeX25519Recipient encodes a native X25519 recipient from a
86 // [crypto/ecdh.X25519] public key. It's meant for plugins that implement
87 // identities that are compatible with native recipients.
88
func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
89
if pk.Curve() != ecdh.X25519() {
90
return "", fmt.Errorf("wrong ecdh Curve")
91
}
92
return bech32.Encode("age", pk.Bytes())
93 }
94
95 // EncodeHybridRecipient encodes a native MLKEM768-X25519 recipient from a
96 // [crypto/mlkem.EncapsulationKey768] and a [crypto/ecdh.X25519] public key.
97 // It's meant for plugins that implement identities that are compatible with
98 // native recipients.
99
func EncodeHybridRecipient(pq *mlkem.EncapsulationKey768, t *ecdh.PublicKey) (string, error) {
100
if t.Curve() != ecdh.X25519() {
101
return "", fmt.Errorf("wrong ecdh Curve")
102
}
103
pk, err := hpke.NewHybridPublicKey(pq, t)
104
if err != nil {
105
return "", fmt.Errorf("failed to create hybrid public key: %v", err)
106
}
107
return bech32.Encode("age1pq", pk.Bytes())
108 }
109
filippo.io/age/plugin/plugin.go 0.0%
1 // Package plugin implements the age plugin protocol.
2 //
3 // [Recipient] and [Indentity] are plugin clients, that execute plugin binaries to
4 // perform encryption and decryption operations.
5 //
6 // [Plugin] is a framework for writing age plugins, that exposes an [age.Recipient]
7 // and/or [age.Identity] implementation as a plugin binary.
8 package plugin
9
10 import (
11 "bufio"
12 "errors"
13 "flag"
14 "fmt"
15 "io"
16 "os"
17 "strconv"
18
19 "filippo.io/age"
20 "filippo.io/age/internal/format"
21 )
22
23 // TODO: add plugin test framework.
24
25 // Plugin is a framework for writing age plugins. It allows exposing regular
26 // [age.Recipient] and [age.Identity] implementations as plugins, and handles
27 // all the protocol details.
28 type Plugin struct {
29 name string
30 fs *flag.FlagSet
31 sm *string
32
33 recipient func([]byte) (age.Recipient, error)
34 idAsRecipient func([]byte) (age.Recipient, error)
35 identity func([]byte) (age.Identity, error)
36
37 stdin io.Reader
38 stdout, stderr io.Writer
39
40 sr *format.StanzaReader
41 // broken is set if the protocol broke down during an interaction function
42 // called by a Recipient or Identity.
43 broken bool
44 }
45
46 // New creates a new Plugin with the given name.
47 //
48 // For example, a plugin named "frood" would be invoked as "age-plugin-frood".
49
func New(name string) (*Plugin, error) {
50
return &Plugin{name: name, stdin: os.Stdin,
51
stdout: os.Stdout, stderr: os.Stderr}, nil
52
}
53
54 // Name returns the name of the plugin.
55
func (p *Plugin) Name() string {
56
return p.name
57
}
58
59 // RegisterFlags registers the plugin's flags with the given [flag.FlagSet], or
60 // with the default [flag.CommandLine] if fs is nil. It must be called before
61 // [flag.Parse] and [Plugin.Main].
62 //
63 // This allows the plugin to expose additional flags when invoked manually, for
64 // example to implement a keygen mode.
65
func (p *Plugin) RegisterFlags(fs *flag.FlagSet) {
66
if fs == nil {
67
fs = flag.CommandLine
68
}
69
p.fs = fs
70
p.sm = fs.String("age-plugin", "", "age-plugin state machine")
71 }
72
73 // HandleRecipient registers a function to parse recipients of the form
74 // age1name1... into [age.Recipient] values. data is the decoded Bech32 payload.
75 //
76 // If the returned Recipient implements [age.RecipientWithLabels], Plugin will
77 // use it and enforce consistency across every returned stanza in an execution.
78 // If the client supports labels, they will be passed through the protocol.
79 //
80 // It must be called before [Plugin.Main], and can be called at most once.
81 // Otherwise, it panics.
82
func (p *Plugin) HandleRecipient(f func(data []byte) (age.Recipient, error)) {
83
if p.recipient != nil {
84
panic("HandleRecipient called twice")
85 }
86
p.recipient = f
87 }
88
89 // HandleIdentityAsRecipient registers a function to parse identities of the
90 // form AGE-PLUGIN-NAME-1... into [age.Recipient] values, for when identities
91 // are used as recipients. data is the decoded Bech32 payload.
92 //
93 // If the returned Recipient implements [age.RecipientWithLabels], Plugin will
94 // use it and enforce consistency across every returned stanza in an execution.
95 // If the client supports labels, they will be passed through the protocol.
96 //
97 // It must be called before [Plugin.Main], and can be called at most once.
98 // Otherwise, it panics.
99
func (p *Plugin) HandleIdentityAsRecipient(f func(data []byte) (age.Recipient, error)) {
100
if p.idAsRecipient != nil {
101
panic("HandleIdentityAsRecipient called twice")
102 }
103
p.idAsRecipient = f
104 }
105
106 // HandleIdentity registers a function to parse identities of the form
107 // AGE-PLUGIN-NAME-1... into [age.Identity] values. data is the decoded Bech32
108 // payload.
109 //
110 // It must be called before [Plugin.Main], and can be called at most once.
111 // Otherwise, it panics.
112
func (p *Plugin) HandleIdentity(f func(data []byte) (age.Identity, error)) {
113
if p.identity != nil {
114
panic("HandleIdentity called twice")
115 }
116
p.identity = f
117 }
118
119 // HandleRecipientEncoding is like [Plugin.HandleRecipient] but provides the
120 // full recipient encoding string to the callback.
121 //
122 // It allows using functions like ParseRecipient directly.
123
func (p *Plugin) HandleRecipientEncoding(f func(recipient string) (age.Recipient, error)) {
124
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
125
return f(EncodeRecipient(p.name, data))
126
})
127 }
128
129 // HandleIdentityEncodingAsRecipient is like [Plugin.HandleIdentityAsRecipient] but
130 // provides the full identity encoding string to the callback.
131
func (p *Plugin) HandleIdentityEncodingAsRecipient(f func(identity string) (age.Recipient, error)) {
132
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
133
return f(EncodeIdentity(p.name, data))
134
})
135 }
136
137 // HandleIdentityEncoding is like [Plugin.HandleIdentity] but provides the
138 // full identity encoding string to the callback.
139 //
140 // It allows using functions like ParseIdentity directly.
141
func (p *Plugin) HandleIdentityEncoding(f func(identity string) (age.Identity, error)) {
142
p.HandleIdentity(func(data []byte) (age.Identity, error) {
143
return f(EncodeIdentity(p.name, data))
144
})
145 }
146
147 // Main runs the plugin protocol. It returns an exit code to pass to os.Exit.
148 //
149 // It automatically calls [Plugin.RegisterFlags] and [flag.Parse] if they were
150 // not called before.
151
func (p *Plugin) Main() int {
152
if p.fs == nil {
153
p.RegisterFlags(nil)
154
}
155
if !p.fs.Parsed() {
156
p.fs.Parse(os.Args[1:])
157
}
158
if *p.sm == "recipient-v1" {
159
return p.RecipientV1()
160
}
161
if *p.sm == "identity-v1" {
162
return p.IdentityV1()
163
}
164
fmt.Fprintf(p.stderr, "unknown state machine %q", *p.sm)
165
return 4
166 }
167
168 // SetIO sets the plugin's input and output streams, which default to
169 // stdin/stdout/stderr.
170 //
171 // It must be called before [Plugin.Main].
172
func (p *Plugin) SetIO(stdin io.Reader, stdout, stderr io.Writer) {
173
p.stdin = stdin
174
p.stdout = stdout
175
p.stderr = stderr
176
}
177
178 // RecipientV1 implements the recipient-v1 state machine. It returns an exit
179 // code to pass to os.Exit.
180 //
181 // Most plugins should call [Plugin.Main] instead of this method.
182
func (p *Plugin) RecipientV1() int {
183
if p.recipient == nil && p.idAsRecipient == nil {
184
return p.fatalf("recipient-v1 not supported")
185
}
186
187
var recipientStrings, identityStrings []string
188
var fileKeys [][]byte
189
var supportsLabels bool
190
191
p.sr = format.NewStanzaReader(bufio.NewReader(p.stdin))
192
ReadLoop:
193
for {
194
s, err := p.sr.ReadStanza()
195
if err != nil {
196
return p.fatalf("failed to read stanza: %v", err)
197
}
198
199
switch s.Type {
200
case "add-recipient":
201
if err := expectStanzaWithNoBody(s, 1); err != nil {
202
return p.fatalf("%v", err)
203
}
204
recipientStrings = append(recipientStrings, s.Args[0])
205
case "add-identity":
206
if err := expectStanzaWithNoBody(s, 1); err != nil {
207
return p.fatalf("%v", err)
208
}
209
identityStrings = append(identityStrings, s.Args[0])
210
case "extension-labels":
211
if err := expectStanzaWithNoBody(s, 0); err != nil {
212
return p.fatalf("%v", err)
213
}
214
supportsLabels = true
215
case "wrap-file-key":
216
if err := expectStanzaWithBody(s, 0); err != nil {
217
return p.fatalf("%v", err)
218
}
219
fileKeys = append(fileKeys, s.Body)
220
case "done":
221
if err := expectStanzaWithNoBody(s, 0); err != nil {
222
return p.fatalf("%v", err)
223
}
224
break ReadLoop
225
default:
226 // Unsupported stanzas in uni-directional phases are ignored.
227 }
228 }
229
230
if len(recipientStrings)+len(identityStrings) == 0 {
231
return p.fatalf("no recipients or identities provided")
232
}
233
if len(fileKeys) == 0 {
234
return p.fatalf("no file keys provided")
235
}
236
237
var recipients, identities []age.Recipient
238
for i, s := range recipientStrings {
239
name, data, err := ParseRecipient(s)
240
if err != nil {
241
return p.recipientError(i, err)
242
}
243
if name != p.name {
244
return p.recipientError(i, fmt.Errorf("unsupported plugin name: %q", name))
245
}
246
if p.recipient == nil {
247
return p.recipientError(i, fmt.Errorf("recipient encodings not supported"))
248
}
249
r, err := p.recipient(data)
250
if err != nil {
251
return p.recipientError(i, err)
252
}
253
recipients = append(recipients, r)
254 }
255
for i, s := range identityStrings {
256
name, data, err := ParseIdentity(s)
257
if err != nil {
258
return p.identityError(i, err)
259
}
260
if name != p.name {
261
return p.identityError(i, fmt.Errorf("unsupported plugin name: %q", name))
262
}
263
if p.idAsRecipient == nil {
264
return p.identityError(i, fmt.Errorf("identity encodings not supported"))
265
}
266
r, err := p.idAsRecipient(data)
267
if err != nil {
268
return p.identityError(i, err)
269
}
270
identities = append(identities, r)
271 }
272
273 // Technically labels should be per-file key, but the client-side protocol
274 // extension shipped like this, and it doesn't feel worth making a v2.
275
var labels []string
276
277
stanzas := make([][]*age.Stanza, len(fileKeys))
278
for i, fk := range fileKeys {
279
for j, r := range recipients {
280
ss, ll, err := wrapWithLabels(r, fk)
281
if p.broken {
282
return 2
283
} else if err != nil {
284
return p.recipientError(j, err)
285
}
286
if i == 0 && j == 0 {
287
labels = ll
288
} else if err := checkLabels(ll, labels); err != nil {
289
return p.recipientError(j, err)
290
}
291
stanzas[i] = append(stanzas[i], ss...)
292 }
293
for j, r := range identities {
294
ss, ll, err := wrapWithLabels(r, fk)
295
if p.broken {
296
return 2
297
} else if err != nil {
298
return p.identityError(j, err)
299
}
300
if i == 0 && j == 0 && len(recipients) == 0 {
301
labels = ll
302
} else if err := checkLabels(ll, labels); err != nil {
303
return p.identityError(j, err)
304
}
305
stanzas[i] = append(stanzas[i], ss...)
306 }
307 }
308
309
if sent, err := writeGrease(p.stdout); err != nil {
310
return p.fatalf("failed to write grease: %v", err)
311
} else if sent {
312
if err := expectUnsupported(p.sr); err != nil {
313
return p.fatalf("%v", err)
314
}
315 }
316
317
if supportsLabels {
318
if err := writeStanza(p.stdout, "labels", labels...); err != nil {
319
return p.fatalf("failed to write labels stanza: %v", err)
320
}
321
if err := expectOk(p.sr); err != nil {
322
return p.fatalf("%v", err)
323
}
324 }
325
326
for i, ss := range stanzas {
327
for _, s := range ss {
328
if err := (&format.Stanza{Type: "recipient-stanza",
329
Args: append([]string{fmt.Sprint(i), s.Type}, s.Args...),
330
Body: s.Body}).Marshal(p.stdout); err != nil {
331
return p.fatalf("failed to write recipient-stanza: %v", err)
332
}
333
if err := expectOk(p.sr); err != nil {
334
return p.fatalf("%v", err)
335
}
336 }
337
if sent, err := writeGrease(p.stdout); err != nil {
338
return p.fatalf("failed to write grease: %v", err)
339
} else if sent {
340
if err := expectUnsupported(p.sr); err != nil {
341
return p.fatalf("%v", err)
342
}
343 }
344 }
345
346
if err := writeStanza(p.stdout, "done"); err != nil {
347
return p.fatalf("failed to write done stanza: %v", err)
348
}
349
return 0
350 }
351
352
func wrapWithLabels(r age.Recipient, fileKey []byte) ([]*age.Stanza, []string, error) {
353
if r, ok := r.(age.RecipientWithLabels); ok {
354
return r.WrapWithLabels(fileKey)
355
}
356
s, err := r.Wrap(fileKey)
357
return s, nil, err
358 }
359
360
func checkLabels(ll, labels []string) error {
361
if !slicesEqual(ll, labels) {
362
return fmt.Errorf("labels %q do not match previous recipients %q", ll, labels)
363
}
364
return nil
365 }
366
367 // IdentityV1 implements the identity-v1 state machine. It returns an exit code
368 // to pass to os.Exit.
369 //
370 // Most plugins should call [Plugin.Main] instead of this method.
371
func (p *Plugin) IdentityV1() int {
372
if p.identity == nil {
373
return p.fatalf("identity-v1 not supported")
374
}
375
376
var files [][]*age.Stanza
377
var identityStrings []string
378
379
p.sr = format.NewStanzaReader(bufio.NewReader(p.stdin))
380
ReadLoop:
381
for {
382
s, err := p.sr.ReadStanza()
383
if err != nil {
384
return p.fatalf("failed to read stanza: %v", err)
385
}
386
387
switch s.Type {
388
case "add-identity":
389
if err := expectStanzaWithNoBody(s, 1); err != nil {
390
return p.fatalf("%v", err)
391
}
392
identityStrings = append(identityStrings, s.Args[0])
393
case "recipient-stanza":
394
if len(s.Args) < 2 {
395
return p.fatalf("recipient-stanza stanza has %d arguments, want >=2", len(s.Args))
396
}
397
i, err := strconv.Atoi(s.Args[0])
398
if err != nil {
399
return p.fatalf("failed to parse recipient-stanza stanza argument: %v", err)
400
}
401
ss := &age.Stanza{Type: s.Args[1], Args: s.Args[2:], Body: s.Body}
402
switch i {
403
case len(files):
404
files = append(files, []*age.Stanza{ss})
405
case len(files) - 1:
406
files[len(files)-1] = append(files[len(files)-1], ss)
407
default:
408
return p.fatalf("unexpected file index %d, previous was %d", i, len(files)-1)
409 }
410
case "done":
411
if err := expectStanzaWithNoBody(s, 0); err != nil {
412
return p.fatalf("%v", err)
413
}
414
break ReadLoop
415
default:
416 // Unsupported stanzas in uni-directional phases are ignored.
417 }
418 }
419
420
if len(identityStrings) == 0 {
421
return p.fatalf("no identities provided")
422
}
423
if len(files) == 0 {
424
return p.fatalf("no stanzas provided")
425
}
426
427
var identities []age.Identity
428
for i, s := range identityStrings {
429
name, data, err := ParseIdentity(s)
430
if err != nil {
431
return p.identityError(i, err)
432
}
433
if name != p.name {
434
return p.identityError(i, fmt.Errorf("unsupported plugin name: %q", name))
435
}
436
if p.identity == nil {
437
return p.identityError(i, fmt.Errorf("identity encodings not supported"))
438
}
439
r, err := p.identity(data)
440
if err != nil {
441
return p.identityError(i, err)
442
}
443
identities = append(identities, r)
444 }
445
446
for i, ss := range files {
447
if sent, err := writeGrease(p.stdout); err != nil {
448
return p.fatalf("failed to write grease: %v", err)
449
} else if sent {
450
if err := expectUnsupported(p.sr); err != nil {
451
return p.fatalf("%v", err)
452
}
453 }
454
455 // TODO: there should be a mechanism to let the plugin decide the order
456 // in which identities are tried.
457
for _, id := range identities {
458
fk, err := id.Unwrap(ss)
459
if p.broken {
460
return 2
461
} else if errors.Is(err, age.ErrIncorrectIdentity) {
462
continue
463
} else if err != nil {
464
if err := p.writeError([]string{"stanza", fmt.Sprint(i), "0"}, err); err != nil {
465
return p.fatalf("%v", err)
466
}
467 // Note that we don't exit here, as the protocol allows
468 // continuing with other files.
469
break
470 }
471
472
s := &format.Stanza{Type: "file-key", Args: []string{fmt.Sprint(i)}, Body: fk}
473
if err := s.Marshal(p.stdout); err != nil {
474
return p.fatalf("failed to write file-key: %v", err)
475
}
476
if err := expectOk(p.sr); err != nil {
477
return p.fatalf("%v", err)
478
}
479
break
480 }
481 }
482
483
if err := writeStanza(p.stdout, "done"); err != nil {
484
return p.fatalf("failed to write done stanza: %v", err)
485
}
486
return 0
487 }
488
489 // DisplayMessage requests that the client display a message to the user. The
490 // message should start with a lowercase letter and have no final period.
491 // DisplayMessage returns an error if the client can't display the message, and
492 // may return before the message has been displayed to the user.
493 //
494 // It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
495
func (p *Plugin) DisplayMessage(message string) error {
496
if err := writeStanzaWithBody(p.stdout, "msg", []byte(message)); err != nil {
497
return p.fatalInteractf("failed to write msg stanza: %v", err)
498
}
499
s, err := readOkOrFail(p.sr)
500
if err != nil {
501
return p.fatalInteractf("%v", err)
502
}
503
if s.Type == "fail" {
504
return fmt.Errorf("client failed to display message")
505
}
506
if err := expectStanzaWithNoBody(s, 0); err != nil {
507
return p.fatalInteractf("%v", err)
508
}
509
return nil
510 }
511
512 // RequestValue requests a secret or public input from the user through the
513 // client, with the provided prompt. It returns an error if the client can't
514 // request the input or if the user dismisses the prompt.
515 //
516 // It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
517
func (p *Plugin) RequestValue(prompt string, secret bool) (string, error) {
518
t := "request-public"
519
if secret {
520
t = "request-secret"
521
}
522
if err := writeStanzaWithBody(p.stdout, t, []byte(prompt)); err != nil {
523
return "", p.fatalInteractf("failed to write stanza: %v", err)
524
}
525
s, err := readOkOrFail(p.sr)
526
if err != nil {
527
return "", p.fatalInteractf("%v", err)
528
}
529
if s.Type == "fail" {
530
return "", fmt.Errorf("client failed to request value")
531
}
532
if err := expectStanzaWithBody(s, 0); err != nil {
533
return "", p.fatalInteractf("%v", err)
534
}
535
return string(s.Body), nil
536 }
537
538 // Confirm requests a confirmation from the user through the client, with the
539 // provided prompt. The yes and no value are the choices provided to the user.
540 // no may be empty. The return value choseYes indicates whether the user
541 // selected the yes or no option. Confirm returns an error if the client can't
542 // request the confirmation.
543 //
544 // It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
545
func (p *Plugin) Confirm(prompt, yes, no string) (choseYes bool, err error) {
546
args := []string{format.EncodeToString([]byte(yes))}
547
if no != "" {
548
args = append(args, format.EncodeToString([]byte(no)))
549
}
550
s := &format.Stanza{Type: "confirm", Args: args, Body: []byte(prompt)}
551
if err := s.Marshal(p.stdout); err != nil {
552
return false, p.fatalInteractf("failed to write confirm stanza: %v", err)
553
}
554
s, err = readOkOrFail(p.sr)
555
if err != nil {
556
return false, p.fatalInteractf("%v", err)
557
}
558
if s.Type == "fail" {
559
return false, fmt.Errorf("client failed to request confirmation")
560
}
561
if err := expectStanzaWithNoBody(s, 1); err != nil {
562
return false, p.fatalInteractf("%v", err)
563
}
564
return s.Args[0] == "yes", nil
565 }
566
567 // fatalInteractf prints the error to stderr and sets the broken flag, so the
568 // Wrap/Unwrap caller can exit with an error.
569
func (p *Plugin) fatalInteractf(format string, args ...any) error {
570
p.broken = true
571
fmt.Fprintf(p.stderr, format, args...)
572
return fmt.Errorf(format, args...)
573
}
574
575
func (p *Plugin) fatalf(format string, args ...any) int {
576
fmt.Fprintf(p.stderr, format, args...)
577
return 1
578
}
579
580
func expectStanzaWithNoBody(s *format.Stanza, wantArgs int) error {
581
if len(s.Args) != wantArgs {
582
return fmt.Errorf("%s stanza has %d arguments, want %d", s.Type, len(s.Args), wantArgs)
583
}
584
if len(s.Body) != 0 {
585
return fmt.Errorf("%s stanza has %d bytes of body, want 0", s.Type, len(s.Body))
586
}
587
return nil
588 }
589
590
func expectStanzaWithBody(s *format.Stanza, wantArgs int) error {
591
if len(s.Args) != wantArgs {
592
return fmt.Errorf("%s stanza has %d arguments, want %d", s.Type, len(s.Args), wantArgs)
593
}
594
if len(s.Body) == 0 {
595
return fmt.Errorf("%s stanza has 0 bytes of body, want >0", s.Type)
596
}
597
return nil
598 }
599
600
func (p *Plugin) recipientError(idx int, err error) int {
601
if err := p.writeError([]string{"recipient", fmt.Sprint(idx)}, err); err != nil {
602
return p.fatalf("%v", err)
603
}
604
return 3
605 }
606
607
func (p *Plugin) identityError(idx int, err error) int {
608
if err := p.writeError([]string{"identity", fmt.Sprint(idx)}, err); err != nil {
609
return p.fatalf("%v", err)
610
}
611
return 3
612 }
613
614
func expectOk(sr *format.StanzaReader) error {
615
ok, err := sr.ReadStanza()
616
if err != nil {
617
return fmt.Errorf("failed to read OK stanza: %v", err)
618
}
619
if ok.Type != "ok" {
620
return fmt.Errorf("expected OK stanza, got %q", ok.Type)
621
}
622
return expectStanzaWithNoBody(ok, 0)
623 }
624
625
func readOkOrFail(sr *format.StanzaReader) (*format.Stanza, error) {
626
s, err := sr.ReadStanza()
627
if err != nil {
628
return nil, fmt.Errorf("failed to read response stanza: %v", err)
629
}
630
switch s.Type {
631
case "fail":
632
if err := expectStanzaWithNoBody(s, 0); err != nil {
633
return nil, fmt.Errorf("%v", err)
634
}
635
return s, nil
636
case "ok":
637
return s, nil
638
default:
639
return nil, fmt.Errorf("expected ok or fail stanza, got %q", s.Type)
640 }
641 }
642
643
func expectUnsupported(sr *format.StanzaReader) error {
644
unsupported, err := sr.ReadStanza()
645
if err != nil {
646
return fmt.Errorf("failed to read unsupported stanza: %v", err)
647
}
648
if unsupported.Type != "unsupported" {
649
return fmt.Errorf("expected unsupported stanza, got %q", unsupported.Type)
650
}
651
return expectStanzaWithNoBody(unsupported, 0)
652 }
653
654
func (p *Plugin) writeError(args []string, err error) error {
655
s := &format.Stanza{Type: "error", Args: args}
656
s.Body = []byte(err.Error())
657
if err := s.Marshal(p.stdout); err != nil {
658
return fmt.Errorf("failed to write error stanza: %v", err)
659
}
660
if err := expectOk(p.sr); err != nil {
661
return fmt.Errorf("%v", err)
662
}
663
return nil
664 }
665
666
func slicesEqual(s1, s2 []string) bool {
667
if len(s1) != len(s2) {
668
return false
669
}
670
for i := range s1 {
671
if s1[i] != s2[i] {
672
return false
673
}
674 }
675
return true
676 }
677
filippo.io/age/plugin/tui.go 0.0%
1 package plugin
2
3 import (
4 "errors"
5 "fmt"
6
7 "filippo.io/age/internal/term"
8 )
9
10 // NewTerminalUI returns a [ClientUI] that uses the terminal to request inputs,
11 // and the provided functions to display messages and errors.
12 //
13 // The terminal is reached directly through /dev/tty or CONIN$/CONOUT$,
14 // bypassing standard input and output, so this UI can be used even when
15 // standard input or output are redirected.
16
func NewTerminalUI(printf, warningf func(format string, v ...any)) *ClientUI {
17
return &ClientUI{
18
DisplayMessage: func(name, message string) error {
19
printf("%s plugin: %s", name, message)
20
return nil
21
},
22
RequestValue: func(name, message string, isSecret bool) (s string, err error) {
23
defer func() {
24
if err != nil {
25
warningf("could not read value for age-plugin-%s: %v", name, err)
26
}
27 }()
28
if isSecret {
29
secret, err := term.ReadSecret(message)
30
if err != nil {
31
return "", err
32
}
33
return string(secret), nil
34
} else {
35
public, err := term.ReadPublic(message)
36
if err != nil {
37
return "", err
38
}
39
return string(public), nil
40 }
41 },
42
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
43
defer func() {
44
if err != nil {
45
warningf("could not read value for age-plugin-%s: %v", name, err)
46
}
47 }()
48
if no == "" {
49
message += fmt.Sprintf(" (press enter for %q)", yes)
50
_, err := term.ReadSecret(message)
51
if err != nil {
52
return false, err
53
}
54
return true, nil
55 }
56
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
57
for {
58
selection, err := term.ReadCharacter(message)
59
if err != nil {
60
return false, err
61
}
62
switch selection {
63
case '1':
64
return true, nil
65
case '2':
66
return false, nil
67
case '\x03': // CTRL-C
68
return false, errors.New("user cancelled prompt")
69
default:
70
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
71 }
72 }
73 },
74
WaitTimer: func(name string) {
75
printf("waiting on %s plugin...", name)
76
},
77 }
78 }
79
filippo.io/age/pq.go 82.5%
1 // Copyright 2025 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package age
6
7 import (
8 "errors"
9 "fmt"
10 "strings"
11
12 "filippo.io/age/internal/bech32"
13 "filippo.io/age/internal/format"
14 "filippo.io/hpke"
15 "golang.org/x/crypto/chacha20poly1305"
16 )
17
18 const pqLabel = "age-encryption.org/mlkem768x25519"
19
20 // HybridRecipient is the standard age public key. Messages encrypted to
21 // this recipient can be decrypted with the corresponding [HybridIdentity].
22 //
23 // This recipient is safe against future cryptographically-relevant quantum
24 // computers, and can only be used along with other post-quantum recipients.
25 //
26 // This recipient is anonymous, in the sense that an attacker can't tell from
27 // the message alone if it is encrypted to a certain recipient.
28 type HybridRecipient struct {
29 pk hpke.PublicKey
30 }
31
32 var _ Recipient = &HybridRecipient{}
33
34 // newHybridRecipient returns a new [HybridRecipient] from a raw HPKE public key.
35
func newHybridRecipient(publicKey []byte) (*HybridRecipient, error) {
36
pk, err := hpke.MLKEM768X25519().NewPublicKey(publicKey)
37
if err != nil {
38
return nil, errors.New("invalid MLKEM768-X25519 public key")
39
}
40
return &HybridRecipient{pk: pk}, nil
41 }
42
43 // ParseHybridRecipient returns a new [HybridRecipient] from a Bech32 public key
44 // encoding with the "age1pq1" prefix.
45
func ParseHybridRecipient(s string) (*HybridRecipient, error) {
46
t, k, err := bech32.Decode(s)
47
if err != nil {
48
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
49
}
50
if t != "age1pq" {
51
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
52
}
53
r, err := newHybridRecipient(k)
54
if err != nil {
55
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
56
}
57
return r, nil
58 }
59
60
func (r *HybridRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
61
s, _, err := r.WrapWithLabels(fileKey)
62
return s, err
63
}
64
65 // WrapWithLabels implements [RecipientWithLabels], returning a single
66 // "postquantum" label. This ensures a HybridRecipient can't be mixed with other
67 // recipients that would defeat its post-quantum security.
68 //
69 // To unsafely bypass this restriction, wrap HybridRecipient in a [Recipient]
70 // type that doesn't expose WrapWithLabels.
71
func (r *HybridRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
72
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
73
if err != nil {
74
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
75
}
76
ct, err := s.Seal(nil, fileKey)
77
if err != nil {
78
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
79
}
80
81
l := &Stanza{
82
Type: "mlkem768x25519",
83
Args: []string{format.EncodeToString(enc)},
84
Body: ct,
85
}
86
87
return []*Stanza{l}, []string{"postquantum"}, nil
88 }
89
90 // String returns the Bech32 public key encoding of r.
91
func (r *HybridRecipient) String() string {
92
s, _ := bech32.Encode("age1pq", r.pk.Bytes())
93
return s
94
}
95
96 // HybridIdentity is the standard age private key, which can decrypt messages
97 // encrypted to the corresponding [HybridRecipient].
98 type HybridIdentity struct {
99 k hpke.PrivateKey
100 }
101
102 var _ Identity = &HybridIdentity{}
103
104 // newHybridIdentity returns a new [HybridIdentity] from a raw HPKE private key.
105
func newHybridIdentity(secretKey []byte) (*HybridIdentity, error) {
106
k, err := hpke.MLKEM768X25519().NewPrivateKey(secretKey)
107
if err != nil {
108
return nil, errors.New("invalid MLKEM768-X25519 secret key")
109
}
110
return &HybridIdentity{k: k}, nil
111 }
112
113 // GenerateHybridIdentity randomly generates a new [HybridIdentity].
114
func GenerateHybridIdentity() (*HybridIdentity, error) {
115
k, err := hpke.MLKEM768X25519().GenerateKey()
116
if err != nil {
117
return nil, fmt.Errorf("failed to generate post-quantum identity: %v", err)
118
}
119
return &HybridIdentity{k: k}, nil
120 }
121
122 // ParseHybridIdentity returns a new [HybridIdentity] from a Bech32 private key
123 // encoding with the "AGE-SECRET-KEY-PQ-1" prefix.
124
func ParseHybridIdentity(s string) (*HybridIdentity, error) {
125
t, k, err := bech32.Decode(s)
126
if err != nil {
127
return nil, fmt.Errorf("malformed secret key: %v", err)
128
}
129
if t != "AGE-SECRET-KEY-PQ-" {
130
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
131
}
132
r, err := newHybridIdentity(k)
133
if err != nil {
134
return nil, fmt.Errorf("malformed secret key: %v", err)
135
}
136
return r, nil
137 }
138
139
func (i *HybridIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
140
return multiUnwrap(i.unwrap, stanzas)
141
}
142
143
func (i *HybridIdentity) unwrap(block *Stanza) ([]byte, error) {
144
if block.Type != "mlkem768x25519" {
145
return nil, ErrIncorrectIdentity
146
}
147
if len(block.Args) != 1 {
148
return nil, errors.New("invalid mlkem768x25519 recipient block")
149
}
150
enc, err := format.DecodeString(block.Args[0])
151
if err != nil {
152
return nil, fmt.Errorf("failed to parse mlkem768x25519 recipient: %v", err)
153
}
154
if len(block.Body) != fileKeySize+chacha20poly1305.Overhead {
155
return nil, errIncorrectCiphertextSize
156
}
157
158
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
159
if err != nil {
160
// MLKEM768-X25519 does implicit rejection, so a mismatched key does not
161
// hit this error path, but is only detected later when trying to open.
162
return nil, fmt.Errorf("invalid mlkem768x25519 recipient: %v", err)
163
}
164
fileKey, err := r.Open(nil, block.Body)
165
if err != nil {
166
return nil, ErrIncorrectIdentity
167
}
168
return fileKey, nil
169 }
170
171 // Recipient returns the public [HybridRecipient] value corresponding to i.
172
func (i *HybridIdentity) Recipient() *HybridRecipient {
173
return &HybridRecipient{pk: i.k.PublicKey()}
174
}
175
176 // String returns the Bech32 private key encoding of i.
177
func (i *HybridIdentity) String() string {
178
b, _ := i.k.Bytes()
179
s, _ := bech32.Encode("AGE-SECRET-KEY-PQ-", b)
180
return strings.ToUpper(s)
181
}
182
filippo.io/age/primitives.go 80.0%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package age
6
7 import (
8 "crypto/hmac"
9 "crypto/sha256"
10 "errors"
11 "io"
12
13 "filippo.io/age/internal/format"
14 "golang.org/x/crypto/chacha20poly1305"
15 "golang.org/x/crypto/hkdf"
16 )
17
18 // aeadEncrypt encrypts a message with a one-time key.
19
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
20
aead, err := chacha20poly1305.New(key)
21
if err != nil {
22
return nil, err
23
}
24 // The nonce is fixed because this function is only used in places where the
25 // spec guarantees each key is only used once (by deriving it from values
26 // that include fresh randomness), allowing us to save the overhead.
27 // For the code that encrypts the actual payload, look at the
28 // filippo.io/age/internal/stream package.
29
nonce := make([]byte, chacha20poly1305.NonceSize)
30
return aead.Seal(nil, nonce, plaintext, nil), nil
31 }
32
33 var errIncorrectCiphertextSize = errors.New("encrypted value has unexpected length")
34
35 // aeadDecrypt decrypts a message of an expected fixed size.
36 //
37 // The message size is limited to mitigate multi-key attacks, where a ciphertext
38 // can be crafted that decrypts successfully under multiple keys. Short
39 // ciphertexts can only target two keys, which has limited impact.
40
func aeadDecrypt(key []byte, size int, ciphertext []byte) ([]byte, error) {
41
aead, err := chacha20poly1305.New(key)
42
if err != nil {
43
return nil, err
44
}
45
if len(ciphertext) != size+aead.Overhead() {
46
return nil, errIncorrectCiphertextSize
47
}
48
nonce := make([]byte, chacha20poly1305.NonceSize)
49
return aead.Open(nil, nonce, ciphertext, nil)
50 }
51
52
func headerMAC(fileKey []byte, hdr *format.Header) ([]byte, error) {
53
h := hkdf.New(sha256.New, fileKey, nil, []byte("header"))
54
hmacKey := make([]byte, 32)
55
if _, err := io.ReadFull(h, hmacKey); err != nil {
56
return nil, err
57
}
58
hh := hmac.New(sha256.New, hmacKey)
59
if err := hdr.MarshalWithoutMAC(hh); err != nil {
60
return nil, err
61
}
62
return hh.Sum(nil), nil
63 }
64
65
func streamKey(fileKey, nonce []byte) []byte {
66
h := hkdf.New(sha256.New, fileKey, nonce, []byte("payload"))
67
streamKey := make([]byte, chacha20poly1305.KeySize)
68
if _, err := io.ReadFull(h, streamKey); err != nil {
69
panic("age: internal error: failed to read from HKDF: " + err.Error())
70 }
71
return streamKey
72 }
73
filippo.io/age/scrypt.go 81.4%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package age
6
7 import (
8 "crypto/rand"
9 "encoding/hex"
10 "errors"
11 "fmt"
12 "regexp"
13 "strconv"
14
15 "filippo.io/age/internal/format"
16 "golang.org/x/crypto/chacha20poly1305"
17 "golang.org/x/crypto/scrypt"
18 )
19
20 const scryptLabel = "age-encryption.org/v1/scrypt"
21
22 // ScryptRecipient is a password-based recipient. Anyone with the password can
23 // decrypt the message.
24 //
25 // If a ScryptRecipient is used, it must be the only recipient for the file: it
26 // can't be mixed with other recipient types and can't be used multiple times
27 // for the same file.
28 //
29 // Its use is not recommended for automated systems, which should prefer
30 // [HybridRecipient] or [X25519Recipient].
31 type ScryptRecipient struct {
32 password []byte
33 workFactor int
34 }
35
36 var _ Recipient = &ScryptRecipient{}
37
38 // NewScryptRecipient returns a new ScryptRecipient with the provided password.
39
func NewScryptRecipient(password string) (*ScryptRecipient, error) {
40
if len(password) == 0 {
41
return nil, errors.New("passphrase can't be empty")
42
}
43
r := &ScryptRecipient{
44
password: []byte(password),
45
// TODO: automatically scale this to 1s (with a min) in the CLI.
46
workFactor: 18, // 1s on a modern machine
47
}
48
return r, nil
49 }
50
51 // SetWorkFactor sets the scrypt work factor to 2^logN.
52 // It must be called before Wrap.
53 //
54 // If SetWorkFactor is not called, a reasonable default is used.
55
func (r *ScryptRecipient) SetWorkFactor(logN int) {
56
if logN > 30 || logN < 1 {
57
panic("age: SetWorkFactor called with illegal value")
58 }
59
r.workFactor = logN
60 }
61
62 const scryptSaltSize = 16
63
64
func (r *ScryptRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
65
salt := make([]byte, scryptSaltSize)
66
if _, err := rand.Read(salt[:]); err != nil {
67
return nil, err
68
}
69
70
logN := r.workFactor
71
l := &Stanza{
72
Type: "scrypt",
73
Args: []string{format.EncodeToString(salt), strconv.Itoa(logN)},
74
}
75
76
salt = append([]byte(scryptLabel), salt...)
77
k, err := scrypt.Key(r.password, salt, 1<<logN, 8, 1, chacha20poly1305.KeySize)
78
if err != nil {
79
return nil, fmt.Errorf("failed to generate scrypt hash: %v", err)
80
}
81
82
wrappedKey, err := aeadEncrypt(k, fileKey)
83
if err != nil {
84
return nil, err
85
}
86
l.Body = wrappedKey
87
88
return []*Stanza{l}, nil
89 }
90
91 // WrapWithLabels implements [age.RecipientWithLabels], returning a random
92 // label. This ensures a ScryptRecipient can't be mixed with other recipients
93 // (including other ScryptRecipients).
94 //
95 // Users reasonably expect files encrypted to a passphrase to be [authenticated]
96 // by that passphrase, i.e. for it to be impossible to produce a file that
97 // decrypts successfully with a passphrase without knowing it. If a file is
98 // encrypted to other recipients, those parties can produce different files that
99 // would break that expectation.
100 //
101 // [authenticated]: https://words.filippo.io/dispatches/age-authentication/
102
func (r *ScryptRecipient) WrapWithLabels(fileKey []byte) (stanzas []*Stanza, labels []string, err error) {
103
stanzas, err = r.Wrap(fileKey)
104
105
random := make([]byte, 16)
106
if _, err := rand.Read(random); err != nil {
107
return nil, nil, err
108
}
109
labels = []string{hex.EncodeToString(random)}
110
111
return
112 }
113
114 // ScryptIdentity is a password-based identity.
115 type ScryptIdentity struct {
116 password []byte
117 maxWorkFactor int
118 }
119
120 var _ Identity = &ScryptIdentity{}
121
122 // NewScryptIdentity returns a new ScryptIdentity with the provided password.
123
func NewScryptIdentity(password string) (*ScryptIdentity, error) {
124
if len(password) == 0 {
125
return nil, errors.New("passphrase can't be empty")
126
}
127
i := &ScryptIdentity{
128
password: []byte(password),
129
maxWorkFactor: 22, // 15s on a modern machine
130
}
131
return i, nil
132 }
133
134 // SetMaxWorkFactor sets the maximum accepted scrypt work factor to 2^logN.
135 // It must be called before Unwrap.
136 //
137 // This caps the amount of work that Decrypt might have to do to process
138 // received files. If SetMaxWorkFactor is not called, a fairly high default is
139 // used, which might not be suitable for systems processing untrusted files.
140
func (i *ScryptIdentity) SetMaxWorkFactor(logN int) {
141
if logN > 30 || logN < 1 {
142
panic("age: SetMaxWorkFactor called with illegal value")
143 }
144
i.maxWorkFactor = logN
145 }
146
147
func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
148
for _, s := range stanzas {
149
if s.Type == "scrypt" && len(stanzas) != 1 {
150
return nil, errors.New("an scrypt recipient must be the only one")
151
}
152 }
153
for _, s := range stanzas {
154
if s.Type != "scrypt" {
155
continue
156 }
157
return i.unwrap(s)
158 }
159
return nil, fmt.Errorf("%w: file is not passphrase-encrypted", ErrIncorrectIdentity)
160 }
161
162 var digitsRe = regexp.MustCompile(`^[1-9][0-9]*$`)
163
164
func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
165
if block.Type != "scrypt" {
166
return nil, errors.New("internal error: unwrap called on non-scrypt stanza")
167
}
168
if len(block.Args) != 2 {
169
return nil, errors.New("invalid scrypt recipient block")
170
}
171
salt, err := format.DecodeString(block.Args[0])
172
if err != nil {
173
return nil, fmt.Errorf("failed to parse scrypt salt: %v", err)
174
}
175
if len(salt) != scryptSaltSize {
176
return nil, errors.New("invalid scrypt recipient block")
177
}
178
if w := block.Args[1]; !digitsRe.MatchString(w) {
179
return nil, fmt.Errorf("scrypt work factor encoding invalid: %q", w)
180
}
181
logN, err := strconv.Atoi(block.Args[1])
182
if err != nil {
183
return nil, fmt.Errorf("failed to parse scrypt work factor: %v", err)
184
}
185
if logN > i.maxWorkFactor {
186
return nil, fmt.Errorf("scrypt work factor too large: %v", logN)
187
}
188
if logN <= 0 { // unreachable
189
return nil, fmt.Errorf("invalid scrypt work factor: %v", logN)
190
}
191
192
salt = append([]byte(scryptLabel), salt...)
193
k, err := scrypt.Key(i.password, salt, 1<<logN, 8, 1, chacha20poly1305.KeySize)
194
if err != nil { // unreachable
195
return nil, fmt.Errorf("failed to generate scrypt hash: %v", err)
196
}
197
198 // This AEAD is not robust, so an attacker could craft a message that
199 // decrypts under two different keys (meaning two different passphrases) and
200 // then use an error side-channel in an online decryption oracle to learn if
201 // either key is correct. This is deemed acceptable because the use case (an
202 // online decryption oracle) is not recommended, and the security loss is
203 // only one bit. This also does not bypass any scrypt work, although that work
204 // can be precomputed in an online oracle scenario.
205
fileKey, err := aeadDecrypt(k, fileKeySize, block.Body)
206
if err == errIncorrectCiphertextSize {
207
return nil, errors.New("invalid scrypt recipient block: incorrect file key size")
208
} else if err != nil {
209
// Wrap [ErrIncorrectIdentity] so that multiple passphrases can be tried
210
// in sequence by passing multiple [ScryptIdentity] values to [Decrypt].
211
return nil, fmt.Errorf("%w: incorrect passphrase", ErrIncorrectIdentity)
212
}
213
return fileKey, nil
214 }
215
filippo.io/age/tag/internal/age-plugin-tagtest/plugin-tagtest.go 0.0%
1 // Command age-plugin-tagtest is a that decrypts files encrypted to fixed
2 // age1tag1... or age1tagpq1... recipients for testing purposes.
3 //
4 // It can be used with the "-j" flag:
5 //
6 // go install ./tag/internal/age-plugin-tagtest
7 // age -d -j tagtest file.age
8 package main
9
10 import (
11 "errors"
12 "fmt"
13 "log"
14 "os"
15
16 "filippo.io/age"
17 "filippo.io/age/plugin"
18 "filippo.io/age/tag/internal/tagtest"
19 )
20
21 const classicRecipient = "age1tag1qwe0kafsjrar4txm6heqnhpfuggzr0gvznz7fvygxrlq90u5mq2pysxtw6h"
22
23 const hybridRecipient = "age1tagpq14h4z7cks9sxftfc8tq4xektt4854ur9rv76tvujdvtzk2fmyywkvh9z2emz3x4epvhz7qdt2v7uksyyq2cdzf3k04ny0g5sc3u4heqh3r9v4cnwhfjw0a2azpgmnk9xk02wvywt5szcq6q3jvwsjxvkn3tsk52vqjczdcvc398ym4j6cvqas4w99gkgt7ur3fmt4g873phr23tgxw3f7wgsz9zxz7m8cp27vpq3h5vc8nssjemtr2etmtmqkg4fzn2u9x9zvtysuya5yrytgx482ftx9864h8a6pprarxd3d0qe8nw2at5ekg3tsahtef7kawasxjamyckw2ans6v933vuypcfrra32f89r2v72mka9hhc55s49xe2khfsq7w9r2zynuzfx4fg6v7jjncsc87rw2yy8qp8hr27edus6zw5xd6m3hax2nxhl2dys9792z3wp5c034sfkrxe86guj7pfdh7sytzrufl9euhuhyf9w6c7z2nwf7v5v8f2s4gplgvfx4jj4le22k2qn242qkqkcwx7llfyrct7jm2wcv0ytypeh6h93ezgtd7q6zr428qze3dec5jxlc5xxjetyephp42fljft3s02p0570kjwyfeyjcnks2vglkvyus5g9l4z6m0gf8wu22ygfm40028txwjlxvvgnn7c36z783c6tmc9k5nef8nucj6u3ustff5vhtnzhnscsvsz79wzrkv3sujtntx4wezucy6lp49flmnyydn3khk2xsesw0ekn4u44nzqw2g2rjyrrl7crshlzttgpe0jvqycjzp9kmtz23t3yu0w9j4n344nnrf88k2jqqfpjxte38pcn0epr879pqsuvajxrkmsas89pvfrzwcewneujn08guj5pvvrtn5hzzg2y6u4wwjqqxx4x8w65yc4dchf750dft8kgcttt2f6j0j5v8s7tkaua78tte7artdfar544vl0rau79h95mc4ghp887z82s6rq93txpkvan86n963kagqkldngnkjcn28zdrh38vdxj002zqs9mx7zjvg3ynzdfhfakkynt9fyqpaxpsdrsqrycuhw5ykwgjz6wldef7xtu6p689234hstxe7v8e5422f2dy8ystn57z3fvy9yfrm4t3lye6ejk5n6x8zqexmql7lx965xcxuuy38xzyt8j9qprnwgfqgx54l4tnjdpzdde6xgmwtnpkfwvyr7rkgnavvjn6a3e56wtvjx3evmhjjxvukpq5zqrj0s4sntkz3yeszs5dty8q0q6m7dgp6mjpvaer0c4343g72eycfqzkjupeaemh0n8e935hqs8fh3jgk7fzyxctzuqlx6d2q9jaf8r9wu4sjxj5w6ppw7m9c3hxrzpcv2uek3kxnndgf2hd99q9v2ux8pjkv29ntslvnvhy09dvcy9578rt89gf4cj4cu79zjxtlj3dpct6rjme02zj3qspsade96njkkufu9zuq2lk3qwvddpxjkqm2hnpqwck54zug7ctvkgvk325lwkg4q5rf73zkgys5e9y8jqc96ntdyl4r78lgtw4k5uljk5ttf46s3gc0rq0jwmddnxt875twwq92505zh3zkse5ag2dhjjxyfzkn7xv3j0kv9r3jzpvgep8fq6z8mar509u4fvnhvthp2ah0r45lsyq0mm6fwkcs30v8k9wzvgt6uvcty6qsjvarjs3htym69zu43m4jd3k4tllrr8c05v6p6spuhup4hkk2p9fp9lxafe3pntcn4nk83gzhjjpcjwyg7jcyz5uancu0fakgz27up7ymzp2xv3sqyqewkkqynskw9qkvysrncxj0cy7dt6q8dsseuwmc2urfmcvkykf82wfa54t85hqx8gywhmhzunm2x0d66a4pwl0xl78fhkces5dpq8pfnp35m5a3u8vdam64zx5s5x9cmnrx3zr066f4f8hlecqnq2fd5quw79ljg3q5nvs6ggmm4gkc"
24
25
func init() {
26
c := tagtest.NewClassicIdentity("age-plugin-tagtest").Recipient().String()
27
if c != classicRecipient {
28
log.Fatalf("unexpected classic recipient: %s", c)
29
}
30
h := tagtest.NewHybridIdentity("age-plugin-tagtest").Recipient().String()
31
if h != hybridRecipient {
32
log.Fatalf("unexpected hybrid recipient: %s", h)
33
}
34 }
35
36
func main() {
37
p, err := plugin.New("tagtest")
38
if err != nil {
39
log.Fatal(err)
40
}
41
p.HandleIdentity(func(b []byte) (age.Identity, error) {
42
if len(b) != 0 {
43
return nil, fmt.Errorf("unexpected identity data")
44
}
45
return &tagtestIdentity{}, nil
46 })
47
os.Exit(p.Main())
48 }
49
50 type tagtestIdentity struct{}
51
52
func (i *tagtestIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
53
classic := tagtest.NewClassicIdentity("age-plugin-tagtest")
54
if key, err := classic.Unwrap(ss); err == nil {
55
return key, nil
56
} else if !errors.Is(err, age.ErrIncorrectIdentity) {
57
return nil, err
58
}
59
hybrid := tagtest.NewHybridIdentity("age-plugin-tagtest")
60
return hybrid.Unwrap(ss)
61 }
62
filippo.io/age/tag/internal/tagtest/tagtest.go 0.0%
1 // Copyright 2025 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package tagtest
6
7 import (
8 "crypto/ecdh"
9 "crypto/subtle"
10 "fmt"
11
12 "filippo.io/age"
13 "filippo.io/age/internal/format"
14 "filippo.io/age/tag"
15 "filippo.io/hpke"
16 "filippo.io/nistec"
17 )
18
19 type ClassicIdentity struct {
20 k hpke.PrivateKey
21 }
22
23 var _ age.Identity = &ClassicIdentity{}
24
25
func NewClassicIdentity(seed string) *ClassicIdentity {
26
k, err := hpke.DHKEM(ecdh.P256()).DeriveKeyPair([]byte(seed))
27
if err != nil {
28
panic(fmt.Sprintf("failed to generate key: %v", err))
29 }
30
return &ClassicIdentity{k: k}
31 }
32
33
func (i *ClassicIdentity) Recipient() *tag.Recipient {
34
uncompressed := i.k.PublicKey().Bytes()
35
p, err := nistec.NewP256Point().SetBytes(uncompressed)
36
if err != nil {
37
panic(fmt.Sprintf("failed to parse public key: %v", err))
38 }
39
r, err := tag.NewClassicRecipient(p.BytesCompressed())
40
if err != nil {
41
panic(fmt.Sprintf("failed to create recipient: %v", err))
42 }
43
return r
44 }
45
46
func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
47
for _, s := range ss {
48
if s.Type != "p256tag" {
49
continue
50 }
51
if len(s.Args) != 2 {
52
return nil, fmt.Errorf("malformed stanza")
53
}
54
tagArg, err := format.DecodeString(s.Args[0])
55
if err != nil {
56
return nil, fmt.Errorf("malformed tag: %v", err)
57
}
58
if len(tagArg) != 4 {
59
return nil, fmt.Errorf("invalid tag length: %d", len(tagArg))
60
}
61
enc, err := format.DecodeString(s.Args[1])
62
if err != nil {
63
return nil, fmt.Errorf("malformed encapsulated key: %v", err)
64
}
65
if len(enc) != 65 {
66
return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc))
67
}
68
if len(s.Body) != 32 {
69
return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body))
70
}
71
72
expTag, err := i.Recipient().Tag(enc)
73
if err != nil {
74
return nil, fmt.Errorf("failed to compute tag: %v", err)
75
}
76
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
77
return nil, age.ErrIncorrectIdentity
78
}
79
80
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/p256tag"))
81
if err != nil {
82
return nil, fmt.Errorf("failed to unwrap file key: %v", err)
83
}
84
return r.Open(nil, s.Body)
85 }
86
return nil, age.ErrIncorrectIdentity
87 }
88
89 type HybridIdentity struct {
90 k hpke.PrivateKey
91 }
92
93 var _ age.Identity = &HybridIdentity{}
94
95
func NewHybridIdentity(seed string) *HybridIdentity {
96
k, err := hpke.MLKEM768P256().DeriveKeyPair([]byte(seed))
97
if err != nil {
98
panic(fmt.Sprintf("failed to generate key: %v", err))
99 }
100
return &HybridIdentity{k: k}
101 }
102
103
func (i *HybridIdentity) Recipient() *tag.Recipient {
104
r, err := tag.NewHybridRecipient(i.k.PublicKey().Bytes())
105
if err != nil {
106
panic(fmt.Sprintf("failed to create recipient: %v", err))
107 }
108
return r
109 }
110
111
func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
112
for _, s := range ss {
113
if s.Type != "mlkem768p256tag" {
114
continue
115 }
116
if len(s.Args) != 2 {
117
return nil, fmt.Errorf("malformed stanza")
118
}
119
tagArg, err := format.DecodeString(s.Args[0])
120
if err != nil {
121
return nil, fmt.Errorf("malformed tag: %v", err)
122
}
123
if len(tagArg) != 4 {
124
return nil, fmt.Errorf("invalid tag length: %d", len(tagArg))
125
}
126
enc, err := format.DecodeString(s.Args[1])
127
if err != nil {
128
return nil, fmt.Errorf("malformed encapsulated key: %v", err)
129
}
130
if len(enc) != 1153 {
131
return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc))
132
}
133
if len(s.Body) != 32 {
134
return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body))
135
}
136
137
expTag, err := i.Recipient().Tag(enc)
138
if err != nil {
139
return nil, fmt.Errorf("failed to compute tag: %v", err)
140
}
141
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
142
return nil, age.ErrIncorrectIdentity
143
}
144
145
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/mlkem768p256tag"))
146
if err != nil {
147
return nil, fmt.Errorf("failed to unwrap file key: %v", err)
148
}
149
return r.Open(nil, s.Body)
150 }
151
return nil, age.ErrIncorrectIdentity
152 }
153
filippo.io/age/tag/tag.go 74.6%
1 // Copyright 2025 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 // Package tag implements tagged P-256 or hybrid P-256 + ML-KEM-768 recipients,
6 // which can be used with identities stored on hardware keys, usually supported
7 // by dedicated plugins.
8 //
9 // The tag reduces privacy, by allowing an observer to correlate files with a
10 // recipient (but not files amongst them without knowledge of the recipient),
11 // but this is also a desirable property for hardware keys that require user
12 // interaction for each decryption operation.
13 package tag
14
15 import (
16 "crypto/ecdh"
17 "crypto/hkdf"
18 "crypto/mlkem"
19 "crypto/sha256"
20 "fmt"
21 "slices"
22
23 "filippo.io/age"
24 "filippo.io/age/internal/format"
25 "filippo.io/age/plugin"
26 "filippo.io/hpke"
27 "filippo.io/nistec"
28 )
29
30 // Recipient is a tagged P-256 or hybrid P-256 + ML-KEM-768 recipient.
31 //
32 // The latter recipient is safe against future cryptographically-relevant
33 // quantum computers, and can only be used along with other post-quantum
34 // recipients.
35 type Recipient struct {
36 pk hpke.PublicKey
37 }
38
39 var _ age.Recipient = &Recipient{}
40
41 // ParseRecipient returns a new [Recipient] from a Bech32 public key
42 // encoding with the "age1tag1" or "age1tagpq1" prefix.
43
func ParseRecipient(s string) (*Recipient, error) {
44
t, k, err := plugin.ParseRecipient(s)
45
if err != nil {
46
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
47
}
48
switch t {
49
case "tag":
50
r, err := NewClassicRecipient(k)
51
if err != nil {
52
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
53
}
54
return r, nil
55
case "tagpq":
56
r, err := NewHybridRecipient(k)
57
if err != nil {
58
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
59
}
60
return r, nil
61
default:
62
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
63 }
64 }
65
66 const compressedPointSize = 1 + 32
67 const uncompressedPointSize = 1 + 32 + 32
68
69 // NewClassicRecipient returns a new P-256 [Recipient] from a raw public key.
70
func NewClassicRecipient(publicKey []byte) (*Recipient, error) {
71
if len(publicKey) != compressedPointSize {
72
return nil, fmt.Errorf("invalid tag recipient public key size %d", len(publicKey))
73
}
74
p, err := nistec.NewP256Point().SetBytes(publicKey)
75
if err != nil {
76
return nil, fmt.Errorf("invalid tag recipient public key: %v", err)
77
}
78
k, err := hpke.DHKEM(ecdh.P256()).NewPublicKey(p.Bytes())
79
if err != nil {
80
return nil, fmt.Errorf("invalid tag recipient public key: %v", err)
81
}
82
return &Recipient{k}, nil
83 }
84
85 // NewHybridRecipient returns a new hybrid P-256 + ML-KEM-768 [Recipient] from
86 // raw concatenated public keys.
87
func NewHybridRecipient(publicKey []byte) (*Recipient, error) {
88
k, err := hpke.MLKEM768P256().NewPublicKey(publicKey)
89
if err != nil {
90
return nil, fmt.Errorf("invalid tagpq recipient public key: %v", err)
91
}
92
return &Recipient{k}, nil
93 }
94
95 // Hybrid reports whether r is a hybrid P-256 + ML-KEM-768 recipient.
96
func (r *Recipient) Hybrid() bool {
97
return r.pk.KEM().ID() == hpke.MLKEM768P256().ID()
98
}
99
100
func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
101
s, _, err := r.WrapWithLabels(fileKey)
102
return s, err
103
}
104
105 // Tag computes the 4-byte tag for the given ciphertext enc.
106 //
107 // This is a low-level method exposed for use by plugins that implement
108 // identities compatible with tagged recipients.
109
func (r *Recipient) Tag(enc []byte) ([]byte, error) {
110
label, tagRecipient := "age-encryption.org/p256tag", r.Bytes()
111
if r.Hybrid() {
112
label = "age-encryption.org/mlkem768p256tag"
113
// In hybrid mode, the tag is computed over just the P-256 part.
114
tagRecipient = tagRecipient[mlkem.EncapsulationKeySize768:]
115
if len(enc) != mlkem.CiphertextSize768+uncompressedPointSize {
116
return nil, fmt.Errorf("invalid ciphertext size")
117
}
118
} else if len(enc) != uncompressedPointSize {
119
return nil, fmt.Errorf("invalid ciphertext size")
120
}
121
rh := sha256.Sum256(tagRecipient)
122
tag, err := hkdf.Extract(sha256.New, append(slices.Clip(enc), rh[:4]...), []byte(label))
123
if err != nil {
124
return nil, fmt.Errorf("failed to compute tag: %v", err)
125
}
126
return tag[:4], nil
127 }
128
129 // WrapWithLabels implements [age.RecipientWithLabels], returning a single
130 // "postquantum" label if r is a hybrid P-256 + ML-KEM-768 recipient. This
131 // ensures a hybrid Recipient can't be mixed with other recipients that would
132 // defeat its post-quantum security.
133 //
134 // To unsafely bypass this restriction, wrap Recipient in an [age.Recipient]
135 // type that doesn't expose WrapWithLabels.
136
func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
137
label, arg := "age-encryption.org/p256tag", "p256tag"
138
if r.Hybrid() {
139
label, arg = "age-encryption.org/mlkem768p256tag", "mlkem768p256tag"
140
}
141
142
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(label))
143
if err != nil {
144
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
145
}
146
ct, err := s.Seal(nil, fileKey)
147
if err != nil {
148
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
149
}
150
151
tag, err := r.Tag(enc)
152
if err != nil {
153
return nil, nil, fmt.Errorf("failed to compute tag: %v", err)
154
}
155
156
l := &age.Stanza{
157
Type: arg,
158
Args: []string{
159
format.EncodeToString(tag[:4]),
160
format.EncodeToString(enc),
161
},
162
Body: ct,
163
}
164
165
if r.Hybrid() {
166
return []*age.Stanza{l}, []string{"postquantum"}, nil
167
}
168
return []*age.Stanza{l}, nil, nil
169 }
170
171 // Bytes returns the raw recipient encoding.
172
func (r *Recipient) Bytes() []byte {
173
if r.Hybrid() {
174
return r.pk.Bytes()
175
}
176
p, err := nistec.NewP256Point().SetBytes(r.pk.Bytes())
177
if err != nil {
178
panic("internal error: invalid P-256 public key")
179 }
180
return p.BytesCompressed()
181 }
182
183 // String returns the Bech32 public key encoding of r.
184
func (r *Recipient) String() string {
185
if r.Hybrid() {
186
return plugin.EncodeRecipient("tagpq", r.Bytes())
187
}
188
return plugin.EncodeRecipient("tag", r.Bytes())
189 }
190
filippo.io/age/x25519.go 84.1%
1 // Copyright 2019 The age Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package age
6
7 import (
8 "crypto/rand"
9 "crypto/sha256"
10 "errors"
11 "fmt"
12 "io"
13 "strings"
14
15 "filippo.io/age/internal/bech32"
16 "filippo.io/age/internal/format"
17 "golang.org/x/crypto/chacha20poly1305"
18 "golang.org/x/crypto/curve25519"
19 "golang.org/x/crypto/hkdf"
20 )
21
22 const x25519Label = "age-encryption.org/v1/X25519"
23
24 // X25519Recipient is the standard age pre-quantum public key. Messages
25 // encrypted to this recipient can be decrypted with the corresponding
26 // [X25519Identity]. For post-quantum resistance, use [HybridRecipient].
27 //
28 // This recipient is anonymous, in the sense that an attacker can't tell from
29 // the message alone if it is encrypted to a certain recipient.
30 type X25519Recipient struct {
31 theirPublicKey []byte
32 }
33
34 var _ Recipient = &X25519Recipient{}
35
36 // newX25519RecipientFromPoint returns a new X25519Recipient from a raw Curve25519 point.
37
func newX25519RecipientFromPoint(publicKey []byte) (*X25519Recipient, error) {
38
if len(publicKey) != curve25519.PointSize {
39
return nil, errors.New("invalid X25519 public key")
40
}
41
r := &X25519Recipient{
42
theirPublicKey: make([]byte, curve25519.PointSize),
43
}
44
copy(r.theirPublicKey, publicKey)
45
return r, nil
46 }
47
48 // ParseX25519Recipient returns a new X25519Recipient from a Bech32 public key
49 // encoding with the "age1" prefix.
50
func ParseX25519Recipient(s string) (*X25519Recipient, error) {
51
t, k, err := bech32.Decode(s)
52
if err != nil {
53
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
54
}
55
if t != "age" {
56
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
57
}
58
r, err := newX25519RecipientFromPoint(k)
59
if err != nil {
60
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
61
}
62
return r, nil
63 }
64
65
func (r *X25519Recipient) Wrap(fileKey []byte) ([]*Stanza, error) {
66
ephemeral := make([]byte, curve25519.ScalarSize)
67
if _, err := rand.Read(ephemeral); err != nil {
68
return nil, err
69
}
70
ourPublicKey, err := curve25519.X25519(ephemeral, curve25519.Basepoint)
71
if err != nil {
72
return nil, err
73
}
74
75
sharedSecret, err := curve25519.X25519(ephemeral, r.theirPublicKey)
76
if err != nil {
77
return nil, err
78
}
79
80
l := &Stanza{
81
Type: "X25519",
82
Args: []string{format.EncodeToString(ourPublicKey)},
83
}
84
85
salt := make([]byte, 0, len(ourPublicKey)+len(r.theirPublicKey))
86
salt = append(salt, ourPublicKey...)
87
salt = append(salt, r.theirPublicKey...)
88
h := hkdf.New(sha256.New, sharedSecret, salt, []byte(x25519Label))
89
wrappingKey := make([]byte, chacha20poly1305.KeySize)
90
if _, err := io.ReadFull(h, wrappingKey); err != nil {
91
return nil, err
92
}
93
94
wrappedKey, err := aeadEncrypt(wrappingKey, fileKey)
95
if err != nil {
96
return nil, err
97
}
98
l.Body = wrappedKey
99
100
return []*Stanza{l}, nil
101 }
102
103 // String returns the Bech32 public key encoding of r.
104
func (r *X25519Recipient) String() string {
105
s, _ := bech32.Encode("age", r.theirPublicKey)
106
return s
107
}
108
109 // X25519Identity is the standard pre-quantum age private key, which can decrypt
110 // messages encrypted to the corresponding [X25519Recipient]. For post-quantum
111 // resistance, use [HybridIdentity].
112 type X25519Identity struct {
113 secretKey, ourPublicKey []byte
114 }
115
116 var _ Identity = &X25519Identity{}
117
118 // newX25519IdentityFromScalar returns a new X25519Identity from a raw Curve25519 scalar.
119
func newX25519IdentityFromScalar(secretKey []byte) (*X25519Identity, error) {
120
if len(secretKey) != curve25519.ScalarSize {
121
return nil, errors.New("invalid X25519 secret key")
122
}
123
i := &X25519Identity{
124
secretKey: make([]byte, curve25519.ScalarSize),
125
}
126
copy(i.secretKey, secretKey)
127
i.ourPublicKey, _ = curve25519.X25519(i.secretKey, curve25519.Basepoint)
128
return i, nil
129 }
130
131 // GenerateX25519Identity randomly generates a new X25519Identity.
132
func GenerateX25519Identity() (*X25519Identity, error) {
133
secretKey := make([]byte, curve25519.ScalarSize)
134
if _, err := rand.Read(secretKey); err != nil {
135
return nil, fmt.Errorf("internal error: %v", err)
136
}
137
return newX25519IdentityFromScalar(secretKey)
138 }
139
140 // ParseX25519Identity returns a new X25519Identity from a Bech32 private key
141 // encoding with the "AGE-SECRET-KEY-1" prefix.
142
func ParseX25519Identity(s string) (*X25519Identity, error) {
143
t, k, err := bech32.Decode(s)
144
if err != nil {
145
return nil, fmt.Errorf("malformed secret key: %v", err)
146
}
147
if t != "AGE-SECRET-KEY-" {
148
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
149
}
150
r, err := newX25519IdentityFromScalar(k)
151
if err != nil {
152
return nil, fmt.Errorf("malformed secret key: %v", err)
153
}
154
return r, nil
155 }
156
157
func (i *X25519Identity) Unwrap(stanzas []*Stanza) ([]byte, error) {
158
return multiUnwrap(i.unwrap, stanzas)
159
}
160
161
func (i *X25519Identity) unwrap(block *Stanza) ([]byte, error) {
162
if block.Type != "X25519" {
163
return nil, ErrIncorrectIdentity
164
}
165
if len(block.Args) != 1 {
166
return nil, errors.New("invalid X25519 recipient block")
167
}
168
publicKey, err := format.DecodeString(block.Args[0])
169
if err != nil {
170
return nil, fmt.Errorf("failed to parse X25519 recipient: %v", err)
171
}
172
if len(publicKey) != curve25519.PointSize {
173
return nil, errors.New("invalid X25519 recipient block")
174
}
175
176
sharedSecret, err := curve25519.X25519(i.secretKey, publicKey)
177
if err != nil {
178
return nil, fmt.Errorf("invalid X25519 recipient: %v", err)
179
}
180
181
salt := make([]byte, 0, len(publicKey)+len(i.ourPublicKey))
182
salt = append(salt, publicKey...)
183
salt = append(salt, i.ourPublicKey...)
184
h := hkdf.New(sha256.New, sharedSecret, salt, []byte(x25519Label))
185
wrappingKey := make([]byte, chacha20poly1305.KeySize)
186
if _, err := io.ReadFull(h, wrappingKey); err != nil {
187
return nil, err
188
}
189
190
fileKey, err := aeadDecrypt(wrappingKey, fileKeySize, block.Body)
191
if err == errIncorrectCiphertextSize {
192
return nil, errors.New("invalid X25519 recipient block: incorrect file key size")
193
} else if err != nil {
194
return nil, ErrIncorrectIdentity
195
}
196
return fileKey, nil
197 }
198
199 // Recipient returns the public X25519Recipient value corresponding to i.
200
func (i *X25519Identity) Recipient() *X25519Recipient {
201
r := &X25519Recipient{}
202
r.theirPublicKey = i.ourPublicKey
203
return r
204
}
205
206 // String returns the Bech32 private key encoding of i.
207
func (i *X25519Identity) String() string {
208
s, _ := bech32.Encode("AGE-SECRET-KEY-", i.secretKey)
209
return strings.ToUpper(s)
210
}
211