| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |