Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
33 changes: 33 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
57 changes: 51 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,27 @@ 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() {
fmt.Fprintf(os.Stderr, "usage:\n")
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)
}

Expand All @@ -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()
Expand Down
186 changes: 186 additions & 0 deletions share.go
Original file line number Diff line number Diff line change
@@ -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
}