diff --git a/README.md b/README.md index a1279c4..7e234be 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ Usage: 2fa -add [-7] [-8] [-hotp] name 2fa -list 2fa name + 2fa -keygen + 2fa -export -age-recipient PUBLIC_KEY name + 2fa -import -age-identity PRIVATE_KEY_FILE `2fa -add name` adds a new key to the 2fa keychain with the given name. It prints a prompt to standard error and reads a two-factor key from standard @@ -34,6 +37,45 @@ least one-minute accuracy. The keychain is stored unencrypted in the text file `$HOME/.2fa`. +## Sharing Keys Securely + +Share 2FA keys between machines without servers or infrastructure: + +`2fa -keygen` generates an age encryption identity. Save the output to a file (e.g., `~/.age/key.txt`). Share your public key with others who will send you keys. + +`2fa -export -age-recipient PUBLIC_KEY name` encrypts a key for a specific recipient. The recipient's public key is their age public key (looks like `age1...`). Output is encrypted data safe to share via email, Slack, USB, etc. + +`2fa -import -age-identity PRIVATE_KEY_FILE` decrypts and imports a shared key using your private identity file. + +Example workflow: + + # Generate your encryption key + $ 2fa -keygen > ~/.age/key.txt + $ grep "public key:" ~/.age/key.txt + # public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + + # Share your public key with Alice + + # Alice checks what keys she has + $ 2fa -list + github + aws-prod + + # Alice exports her github key for you + $ 2fa -export -age-recipient age1ql3z... github > github.age + + # Alice sends you github.age via email/Slack/etc + + # You import it + $ 2fa -import -age-identity ~/.age/key.txt < github.age + 2fa: imported key "github" (totp, 6 digits) + + # Now you can use it + $ 2fa github + 123456 + +All encryption happens locally. No servers, no APIs, no infrastructure required. + ## Example During GitHub 2FA setup, at the “Scan this barcode with your app” step, diff --git a/go.mod b/go.mod index 330c197..ff2fac4 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module rsc.io/2fa go 1.16 -require github.com/atotto/clipboard v0.1.2 +require ( + filippo.io/age v1.1.1 + github.com/atotto/clipboard v0.1.2 +) diff --git a/go.sum b/go.sum index 243ea9b..92e33a2 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,35 @@ +filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg= +filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE= +filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= +golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index a8d173c..b5cf821 100644 --- a/main.go +++ b/main.go @@ -84,12 +84,17 @@ import ( ) var ( - flagAdd = flag.Bool("add", false, "add a key") - flagList = flag.Bool("list", false, "list keys") - flagHotp = flag.Bool("hotp", false, "add key as HOTP (counter-based) key") - flag7 = flag.Bool("7", false, "generate 7-digit code") - flag8 = flag.Bool("8", false, "generate 8-digit code") - flagClip = flag.Bool("clip", false, "copy code to the clipboard") + flagAdd = flag.Bool("add", false, "add a key") + flagList = flag.Bool("list", false, "list keys") + flagHotp = flag.Bool("hotp", false, "add key as HOTP (counter-based) key") + flag7 = flag.Bool("7", false, "generate 7-digit code") + flag8 = flag.Bool("8", false, "generate 8-digit code") + flagClip = flag.Bool("clip", false, "copy code to the clipboard") + flagExport = flag.Bool("export", false, "export a key (encrypted with age)") + flagImport = flag.Bool("import", false, "import an encrypted key") + flagKeygen = flag.Bool("keygen", false, "generate age identity for sharing") + flagAgeRecipient = flag.String("age-recipient", "", "age public key of recipient (for export)") + flagAgeIdentity = flag.String("age-identity", "", "path to age identity file (for import)") ) func usage() { @@ -97,6 +102,9 @@ func usage() { fmt.Fprintf(os.Stderr, "\t2fa -add [-7] [-8] [-hotp] keyname\n") fmt.Fprintf(os.Stderr, "\t2fa -list\n") fmt.Fprintf(os.Stderr, "\t2fa [-clip] keyname\n") + fmt.Fprintf(os.Stderr, "\t2fa -keygen [-o ~/.age/key.txt]\n") + fmt.Fprintf(os.Stderr, "\t2fa -export -age-recipient AGE_PUBLIC_KEY keyname\n") + fmt.Fprintf(os.Stderr, "\t2fa -import -age-identity ~/.age/key.txt < encrypted.age\n") os.Exit(2) } @@ -106,8 +114,45 @@ func main() { flag.Usage = usage flag.Parse() + // Keygen mode (doesn't need keychain) + if *flagKeygen { + if err := generateAgeIdentity(); err != nil { + log.Fatal(err) + } + return + } + k := readKeychain(filepath.Join(os.Getenv("HOME"), ".2fa")) + // Export mode + if *flagExport { + if flag.NArg() != 1 { + usage() + } + if *flagAgeRecipient == "" { + log.Fatal("--age-recipient required for export") + } + name := flag.Arg(0) + if err := k.exportKey(name, []string{*flagAgeRecipient}, os.Stdout); err != nil { + log.Fatal(err) + } + return + } + + // Import mode + if *flagImport { + if flag.NArg() != 0 { + usage() + } + if *flagAgeIdentity == "" { + log.Fatal("--age-identity required for import") + } + if err := k.importKey(os.Stdin, []string{*flagAgeIdentity}); err != nil { + log.Fatal(err) + } + return + } + if *flagList { if flag.NArg() != 0 { usage() diff --git a/share.go b/share.go new file mode 100644 index 0000000..3289543 --- /dev/null +++ b/share.go @@ -0,0 +1,186 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "bytes" + "encoding/base32" + "encoding/json" + "fmt" + "io" + "log" + "os" + + "filippo.io/age" +) + +// KeyExport represents an exported 2FA key +type KeyExport struct { + Name string `json:"name"` + Digits int `json:"digits"` + Key string `json:"key"` + Type string `json:"type"` // "totp" or "hotp" + Counter uint64 `json:"counter,omitempty"` +} + +// exportKey exports a single key encrypted with age +func (c *Keychain) exportKey(name string, recipients []string, output io.Writer) error { + k, ok := c.keys[name] + if !ok { + return fmt.Errorf("no such key %q", name) + } + + // Parse age recipients + var ageRecipients []age.Recipient + for _, r := range recipients { + recipient, err := age.ParseX25519Recipient(r) + if err != nil { + return fmt.Errorf("invalid age recipient %q: %v", r, err) + } + ageRecipients = append(ageRecipients, recipient) + } + + // Create export structure + export := KeyExport{ + Name: name, + Digits: k.digits, + Key: encodeKey(k.raw), + Type: "totp", + } + + // Check if it's HOTP (has counter) + if k.offset != 0 { + // Read current counter + counter := string(c.data[k.offset : k.offset+counterLen]) + fmt.Sscanf(counter, "%d", &export.Counter) + export.Type = "hotp" + } + + // Marshal to JSON + plaintext, err := json.MarshalIndent(export, "", " ") + if err != nil { + return fmt.Errorf("marshaling export: %v", err) + } + + // Encrypt with age + w, err := age.Encrypt(output, ageRecipients...) + if err != nil { + return fmt.Errorf("creating age encryptor: %v", err) + } + + if _, err := w.Write(plaintext); err != nil { + return fmt.Errorf("encrypting data: %v", err) + } + + if err := w.Close(); err != nil { + return fmt.Errorf("closing encryptor: %v", err) + } + + return nil +} + +// importKey imports an age-encrypted key +func (c *Keychain) importKey(input io.Reader, identityPaths []string) error { + // Parse age identities + var identities []age.Identity + for _, path := range identityPaths { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("opening identity file %q: %v", path, err) + } + defer f.Close() + + ids, err := age.ParseIdentities(f) + if err != nil { + return fmt.Errorf("parsing identity file %q: %v", path, err) + } + identities = append(identities, ids...) + } + + if len(identities) == 0 { + return fmt.Errorf("no identities provided") + } + + // Decrypt with age + r, err := age.Decrypt(input, identities...) + if err != nil { + return fmt.Errorf("decrypting data: %v", err) + } + + // Read plaintext + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + return fmt.Errorf("reading decrypted data: %v", err) + } + + // Unmarshal JSON + var export KeyExport + if err := json.Unmarshal(buf.Bytes(), &export); err != nil { + return fmt.Errorf("parsing export: %v", err) + } + + // Validate key + _, err = decodeKey(export.Key) + if err != nil { + return fmt.Errorf("invalid key in export: %v", err) + } + + // Check if key already exists + if _, exists := c.keys[export.Name]; exists { + return fmt.Errorf("key %q already exists in keychain", export.Name) + } + + // Format key line + line := fmt.Sprintf("%s %d %s", export.Name, export.Digits, export.Key) + if export.Type == "hotp" { + line += " " + fmt.Sprintf("%020d", export.Counter) + } + line += "\n" + + // Append to keychain file + f, err := os.OpenFile(c.file, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("opening keychain: %v", err) + } + defer f.Close() + + f.Chmod(0600) + + if _, err := f.Write([]byte(line)); err != nil { + return fmt.Errorf("writing key: %v", err) + } + + log.Printf("imported key %q (%s, %d digits)", export.Name, export.Type, export.Digits) + + return nil +} + +// encodeKey encodes raw bytes back to base32 +func encodeKey(raw []byte) string { + return base32.StdEncoding.EncodeToString(raw) +} + +// generateAgeIdentity generates a new age identity and prints it +func generateAgeIdentity() error { + identity, err := age.GenerateX25519Identity() + if err != nil { + return fmt.Errorf("generating identity: %v", err) + } + + // Print identity file format (matches age-keygen output) + fmt.Printf("# created: %s\n", os.Getenv("USER")) + fmt.Printf("# public key: %s\n", identity.Recipient()) + fmt.Printf("%s\n", identity) + + // Print instructions to stderr so they don't interfere with piping + log.Printf("") + log.Printf("✓ Age identity generated") + log.Printf("") + log.Printf("Save to file: ./2fa -keygen > ~/.age/key.txt && chmod 600 ~/.age/key.txt") + log.Printf("Your public key: %s", identity.Recipient()) + log.Printf("") + + return nil +}