diff --git a/agessh/agessh.go b/agessh/agessh.go index c8717f4..9b212ba 100644 --- a/agessh/agessh.go +++ b/agessh/agessh.go @@ -102,6 +102,13 @@ func NewRSAIdentity(key *rsa.PrivateKey) (*RSAIdentity, error) { return i, nil } +func (i *RSAIdentity) Recipient() age.Recipient { + return &RSARecipient{ + sshKey: i.sshKey, + pubKey: &i.k.PublicKey, + } +} + func (i *RSAIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { return multiUnwrap(i.unwrap, stanzas) } @@ -303,6 +310,13 @@ func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte { return out[:curve25519.ScalarSize] } +func (i *Ed25519Identity) Recipient() age.Recipient { + return &Ed25519Recipient{ + sshKey: i.sshKey, + theirPublicKey: i.ourPublicKey, + } +} + func (i *Ed25519Identity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { return multiUnwrap(i.unwrap, stanzas) } diff --git a/agessh/agessh_test.go b/agessh/agessh_test.go index ee6a5dd..95417b9 100644 --- a/agessh/agessh_test.go +++ b/agessh/agessh_test.go @@ -11,6 +11,7 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/rsa" + "reflect" "testing" "filippo.io/age/agessh" @@ -36,6 +37,11 @@ func TestSSHRSARoundTrip(t *testing.T) { t.Fatal(err) } + // TODO: replace this with (and go-diff) with go-cmp. + if !reflect.DeepEqual(r, i.Recipient()) { + t.Fatalf("i.Recipient is different from r") + } + fileKey := make([]byte, 16) if _, err := rand.Read(fileKey); err != nil { t.Fatal(err) @@ -74,6 +80,11 @@ func TestSSHEd25519RoundTrip(t *testing.T) { t.Fatal(err) } + // TODO: replace this with (and go-diff) with go-cmp. + if !reflect.DeepEqual(r, i.Recipient()) { + t.Fatalf("i.Recipient is different from r") + } + fileKey := make([]byte, 16) if _, err := rand.Read(fileKey); err != nil { t.Fatal(err) diff --git a/agessh/encrypted_keys.go b/agessh/encrypted_keys.go index ebac9dc..e0e6af9 100644 --- a/agessh/encrypted_keys.go +++ b/agessh/encrypted_keys.go @@ -55,6 +55,10 @@ func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, passphrase f var _ age.Identity = &EncryptedSSHIdentity{} +func (i *EncryptedSSHIdentity) Recipient() (age.Recipient, error) { + return ParseRecipient(string(ssh.MarshalAuthorizedKey(i.pubKey))) +} + // Unwrap implements age.Identity. If the private key is still encrypted, and // any of the stanzas match the public key, it will request the passphrase. The // decrypted private key will be cached after the first successful invocation. diff --git a/cmd/age/age.go b/cmd/age/age.go index d955896..9a3645b 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -18,6 +18,7 @@ import ( "strings" "filippo.io/age" + "filippo.io/age/agessh" "filippo.io/age/armor" "golang.org/x/crypto/ssh/terminal" ) @@ -32,20 +33,22 @@ func (f *multiFlag) Set(value string) error { } const usage = `Usage: - age (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT] - age --passphrase [--armor] [-o OUTPUT] [INPUT] + age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT] + age [--encrypt] --passphrase [--armor] [-o OUTPUT] [INPUT] age --decrypt [-i PATH]... [-o OUTPUT] [INPUT] Options: + -e, --encrypt Encrypt the input to the output. Default if omitted. + -d, --decrypt Decrypt the input to the output. -o, --output OUTPUT Write the result to the file at path OUTPUT. -a, --armor Encrypt to a PEM encoded format. -p, --passphrase Encrypt with a passphrase. -r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated. -R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated. - -d, --decrypt Decrypt the input to the output. -i, --identity PATH Use the identity file at PATH. Can be repeated. INPUT defaults to standard input, and OUTPUT defaults to standard output. +If OUTPUT exists, it will be overwritten. RECIPIENT can be an age public key generated by age-keygen ("age1...") or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA..."). @@ -59,6 +62,9 @@ one per line, or an SSH key. Empty lines and lines starting with "#" are ignored as comments. Multiple key files can be provided, and any unused ones will be ignored. "-" may be used to read identities from standard input. +When --encrypt is specified explicitly, -i can also be used to encrypt to an +identity file symmetrically, instead or in addition to normal recipients. + Example: $ age-keygen -o key.txt Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p @@ -80,16 +86,18 @@ func main() { } var ( - outFlag string - decryptFlag, armorFlag bool - passFlag, versionFlag bool - recipientFlags, identityFlags multiFlag - recipientsFileFlags multiFlag + outFlag string + decryptFlag, encryptFlag bool + passFlag, versionFlag, armorFlag bool + recipientFlags, identityFlags multiFlag + recipientsFileFlags multiFlag ) flag.BoolVar(&versionFlag, "version", false, "print the version") flag.BoolVar(&decryptFlag, "d", false, "decrypt the input") flag.BoolVar(&decryptFlag, "decrypt", false, "decrypt the input") + flag.BoolVar(&encryptFlag, "e", false, "encrypt the input") + flag.BoolVar(&encryptFlag, "encrypt", false, "encrypt the input") flag.BoolVar(&passFlag, "p", false, "use a passphrase") flag.BoolVar(&passFlag, "passphrase", false, "use a passphrase") flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)") @@ -123,6 +131,9 @@ func main() { } switch { case decryptFlag: + if encryptFlag { + logFatalf("Error: -e/--encrypt can't be used with -d/--decrypt.") + } if armorFlag { logFatalf("Error: -a/--armor can't be used with -d/--decrypt.\n" + "Note that armored files are detected automatically.") @@ -140,11 +151,11 @@ func main() { "Did you mean to use -i/--identity to specify a private key?") } default: // encrypt - if len(identityFlags) > 0 { - logFatalf("Error: -i/--identity can't be used in encryption mode.\n" + + if len(identityFlags) > 0 && !encryptFlag { + logFatalf("Error: -i/--identity can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt.\n" + "Did you forget to specify -d/--decrypt?") } - if len(recipientFlags) == 0 && len(recipientsFileFlags) == 0 && !passFlag { + if len(recipientFlags)+len(recipientsFileFlags)+len(identityFlags) == 0 && !passFlag { logFatalf("Error: missing recipients.\n" + "Did you forget to specify -r/--recipient, -R/--recipients-file or -p/--passphrase?") } @@ -154,6 +165,9 @@ func main() { if len(recipientsFileFlags) > 0 && passFlag { logFatalf("Error: -p/--passphrase can't be combined with -R/--recipients-file.") } + if len(identityFlags) > 0 && passFlag { + logFatalf("Error: -p/--passphrase can't be combined with -i/--identity.") + } } var in io.Reader = os.Stdin @@ -202,7 +216,7 @@ func main() { } encryptPass(pass, in, out, armorFlag) default: - encryptKeys(recipientFlags, recipientsFileFlags, in, out, armorFlag) + encryptKeys(recipientFlags, recipientsFileFlags, identityFlags, in, out, armorFlag) } } @@ -233,7 +247,7 @@ func passphrasePromptForEncryption() (string, error) { return p, nil } -func encryptKeys(keys, files []string, in io.Reader, out io.Writer, armor bool) { +func encryptKeys(keys, files, identities []string, in io.Reader, out io.Writer, armor bool) { var recipients []age.Recipient for _, arg := range keys { r, err := parseRecipient(arg) @@ -249,6 +263,19 @@ func encryptKeys(keys, files []string, in io.Reader, out io.Writer, armor bool) } recipients = append(recipients, recs...) } + for _, name := range identities { + ids, err := parseIdentitiesFile(name) + if err != nil { + logFatalf("Error reading %q: %v", name, err) + } + for _, id := range ids { + r, err := identityToRecipient(id) + if err != nil { + logFatalf("Internal error processing %q: %v", name, err) + } + recipients = append(recipients, r) + } + } encrypt(recipients, in, out, armor) } @@ -322,6 +349,20 @@ func passphrasePrompt() (string, error) { return string(pass), nil } +func identityToRecipient(id age.Identity) (age.Recipient, error) { + switch id := id.(type) { + case *age.X25519Identity: + return id.Recipient(), nil + case *agessh.RSAIdentity: + return id.Recipient(), nil + case *agessh.Ed25519Identity: + return id.Recipient(), nil + case *agessh.EncryptedSSHIdentity: + return id.Recipient() + } + return nil, fmt.Errorf("unexpected identity type: %T", id) +} + type lazyOpener struct { name string f *os.File diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 22b8acf..96df4f0 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -109,6 +109,9 @@ func sshKeyType(s string) (string, bool) { return "", false } +// parseIdentitiesFile parses a file that contains age or SSH keys. It returns +// one of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity, or +// *agessh.EncryptedSSHIdentity. func parseIdentitiesFile(name string) ([]age.Identity, error) { var f *os.File if name == "-" {