// SPDX-License-Identifier: MIT // dkekey makes DKE store keys on your own machine. // // A store key is made here, from this machine's random source, and only its // hash and a wrap of the store's data key under it are sent. The account // service never receives the key, and holds no key that opens a store. package main import ( "errors" "flag" "fmt" "io" "os" "strings" ) const usage = `dkekey makes DKE store keys on your own machine. Usage: dkekey mint --store NAME --label NAME [--level LEVEL] [options] dkekey version dkekey help Sign in with one of: --email ADDRESS your account's email; the password is asked for on the terminal, or read from standard input with --password-stdin --token-file FILE an account token made in the dashboard, read from FILE DKEKEY_TOKEN the same token, from the environment Open the store with one of: (nothing) with --email: your customer key, opened by your password --open-with FILE a key that already opens the store, read from FILE: one of its store keys (dke_s_...) or your customer key (dke_c_...). "-" reads standard input. DKEKEY_OPEN_KEY the same key, from the environment Options: --level LEVEL what the new key may do: read (the default), read-add, read-write or read-write-delete --out FILE write the new key to FILE, which must not exist yet, instead of to standard output --api URL the account service (default ` + defaultAPI + `) The new key is written to standard output, or to --out, and to nowhere else. It is shown once: LangSyn does not hold it and cannot show it again. ` var levels = []string{"read", "read-add", "read-write", "read-write-delete"} // environ is what the program reads from outside its arguments, so a test can // give it a world of its own. type environ struct { getenv func(string) string stdin io.Reader stdout io.Writer stderr io.Writer password func(prompt string) (string, error) } func main() { os.Exit(run(os.Args[1:], environ{ getenv: os.Getenv, stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr, password: readPassword, })) } // The exit codes: a key was made; something refused or failed; the command // line was wrong. const ( exitMade = 0 exitFailed = 1 exitUsage = 2 ) func run(args []string, env environ) int { if len(args) == 0 { fmt.Fprint(env.stderr, usage) return exitUsage } switch args[0] { case "mint": return mint(args[1:], env) case "version", "--version": fmt.Fprintf(env.stdout, "dkekey %s (key format %s)\n", version, slotFormat) return exitMade case "help", "-h", "--help": fmt.Fprint(env.stdout, usage) return exitMade } fmt.Fprintf(env.stderr, "dkekey: unknown command %q\n\n%s", args[0], usage) return exitUsage } // request is a mint as the command line asked for it. type request struct { store, label, level string email string passwordStdin bool token string openKey string out string api string } func mint(args []string, env environ) int { req, err := parseMint(args, env) if err != nil { if !errors.Is(err, flag.ErrHelp) { fmt.Fprintf(env.stderr, "dkekey: %v\n\nRun 'dkekey help' for the options.\n", err) } return exitUsage } // The file is claimed before the key is made, so a name already taken // stops the command while nothing has happened. Found taken after the // mint, the key would be registered and lost. var outFile *os.File if req.out != "" { outFile, err = os.OpenFile(req.out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { fmt.Fprintf(env.stderr, "dkekey: cannot write the key to %s: %v\n", req.out, err) return exitFailed } defer outFile.Close() } key, made, err := makeKey(req, env) if err != nil { if outFile != nil { outFile.Close() os.Remove(req.out) } fmt.Fprintf(env.stderr, "dkekey: %v\n", err) return exitFailed } dest := env.stdout if outFile != nil { dest = outFile } if _, err := fmt.Fprintln(dest, key); err != nil { fmt.Fprintf(env.stderr, "dkekey: the key was made but could not be written: %v\n"+ "Revoke the key named %q in the dashboard, and make another.\n", err, req.label) return exitFailed } if outFile != nil { if err := outFile.Sync(); err != nil { fmt.Fprintf(env.stderr, "dkekey: the key was made but could not be saved to %s: %v\n"+ "Revoke the key named %q in the dashboard, and make another.\n", req.out, err, req.label) return exitFailed } } where := "above" if outFile != nil { where = "in " + req.out } fmt.Fprintf(env.stderr, "Made the key %q for the store %q, able to %s. The key is %s.\n"+ "Keep it safe: it is shown once, and LangSyn does not hold it.\n", req.label, made.Store, describeLevel(made.Level), where) return exitMade } func describeLevel(level string) string { switch level { case "read": return "read" case "read-add": return "read and add" case "read-write": return "read and write" case "read-write-delete": return "read, write and delete" } return level } func parseMint(args []string, env environ) (request, error) { var req request var tokenFile, openFile string fs := flag.NewFlagSet("mint", flag.ContinueOnError) fs.SetOutput(io.Discard) fs.StringVar(&req.store, "store", "", "") fs.StringVar(&req.label, "label", "", "") fs.StringVar(&req.level, "level", "read", "") fs.StringVar(&req.email, "email", "", "") fs.BoolVar(&req.passwordStdin, "password-stdin", false, "") fs.StringVar(&tokenFile, "token-file", "", "") fs.StringVar(&openFile, "open-with", "", "") fs.StringVar(&req.out, "out", "", "") fs.StringVar(&req.api, "api", defaultAPI, "") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { fmt.Fprint(env.stdout, usage) } return req, err } if fs.NArg() > 0 { return req, fmt.Errorf("unexpected %q", fs.Arg(0)) } if req.store == "" { return req, errors.New("--store is required: the name of the store the key opens") } if strings.TrimSpace(req.label) == "" { return req, errors.New("--label is required: the name the key is listed under in the dashboard") } if !validLevel(req.level) { return req, fmt.Errorf("--level must be one of %s", strings.Join(levels, ", ")) } // A key or a token given as an argument is visible to every process on the // machine and kept in shell history, so the two flags take a file. for _, f := range []struct{ name, val string }{{"--token-file", tokenFile}, {"--open-with", openFile}} { if strings.HasPrefix(f.val, "dke_") { return req, fmt.Errorf("%s takes a FILE that holds the key, not the key itself: "+ "a key on the command line can be read by other programs on this machine", f.name) } } stdinTaken := "" readSecret := func(flagName, file, envName string) (string, error) { if file == "" { return strings.TrimSpace(env.getenv(envName)), nil } var b []byte var err error if file == "-" { if stdinTaken != "" { return "", fmt.Errorf("%s and %s cannot both read standard input", stdinTaken, flagName) } stdinTaken = flagName b, err = io.ReadAll(io.LimitReader(env.stdin, 64<<10)) } else { b, err = os.ReadFile(file) } if err != nil { return "", fmt.Errorf("cannot read %s: %v", flagName, err) } // Said here, or an empty file reads as no key given at all, and the // customer is told to give one they believe they gave. if s := strings.TrimSpace(string(b)); s != "" { return s, nil } return "", fmt.Errorf("%s %s is empty", flagName, file) } if req.passwordStdin { if req.email == "" { return req, errors.New("--password-stdin needs --email") } stdinTaken = "--password-stdin" } var err error if req.token, err = readSecret("--token-file", tokenFile, "DKEKEY_TOKEN"); err != nil { return req, err } if req.openKey, err = readSecret("--open-with", openFile, "DKEKEY_OPEN_KEY"); err != nil { return req, err } switch { case req.email != "" && req.token != "": return req, errors.New("sign in one way: --email, or an account token, not both " + "(is DKEKEY_TOKEN set?)") case req.email == "" && req.token == "": return req, errors.New("sign in with --email, or with an account token " + "(--token-file, or DKEKEY_TOKEN)") case req.token != "" && req.openKey == "": return req, errors.New("an account token signs in, but opens no store: give a key that " + "opens it with --open-with or DKEKEY_OPEN_KEY") } if req.token != "" && !strings.HasPrefix(req.token, "dke_a_") { return req, errors.New("that is not an account token: an account token begins dke_a_") } if req.openKey != "" && !strings.HasPrefix(req.openKey, storeKeyPrefix) { if _, ok := parseCustomerKey(req.openKey); !ok { return req, errors.New("the key that opens the store must be one of its store keys " + "(dke_s_...) or your customer key (dke_c_...)") } } return req, nil } func validLevel(l string) bool { for _, v := range levels { if l == v { return true } } return false } // makeKey signs in, opens the store, and makes the key. It answers the key // and what the account service says it took. func makeKey(req request, env environ) (string, minted, error) { acct := newAccount(req.api) var customerID string var master []byte defer func() { clear(master) }() if req.email != "" { password, err := askPassword(req, env) if err != nil { return "", minted{}, err } if password == "" { return "", minted{}, errors.New("no password was given") } if master, err = masterKey(password, req.email); err != nil { return "", minted{}, err } proof, err := loginProof(master) if err != nil { return "", minted{}, err } if customerID, err = acct.signIn(req.email, proof); err != nil { return "", minted{}, explainSignIn(err) } defer acct.signOut() } else { acct.token = req.token who, err := acct.whoAmI() if err != nil { return "", minted{}, fmt.Errorf("could not sign in with the account token: %w", err) } customerID = who.CustomerID } dek, err := openStore(acct, req, customerID, master) if err != nil { return "", minted{}, err } defer clear(dek) key, err := newStoreKey() if err != nil { return "", minted{}, err } wrapped, err := makeSlot(dek, key, customerID) if err != nil { return "", minted{}, err } made, err := acct.addKey(req.store, keyHash(key), wrapped, req.level, strings.TrimSpace(req.label)) if err != nil { return "", minted{}, explainMint(err, req) } return key, made, nil } func askPassword(req request, env environ) (string, error) { if req.passwordStdin { b, err := io.ReadAll(io.LimitReader(env.stdin, 64<<10)) if err != nil { return "", err } return strings.TrimRight(string(b), "\r\n"), nil } return env.password("Password for " + normEmail(req.email) + ": ") } // openStore answers the store's data key, opened with the key the command // line gave or, signed in with a password, with the customer key. func openStore(acct *account, req request, customerID string, master []byte) ([]byte, error) { if strings.HasPrefix(req.openKey, storeKeyPrefix) { wrapped, err := acct.slot(req.store, keyHash(req.openKey)) if err != nil { var r *refusal if errors.As(err, &r) && r.status == 404 { return nil, fmt.Errorf("the key you gave does not open the store %q: "+ "check that the store has that name and the key is one of its current keys", req.store) } return nil, fmt.Errorf("could not open the store %q: %w", req.store, err) } dek, err := openSlot(wrapped, req.openKey, customerID) if err != nil { return nil, fmt.Errorf("the key you gave does not open the store %q", req.store) } return dek, nil } var customerKey []byte if req.openKey != "" { customerKey, _ = parseCustomerKey(req.openKey) } else { held, err := acct.customerKey() if err != nil { return nil, fmt.Errorf("could not read your customer key: %w", err) } if held.Wrap == nil { return nil, errors.New("your account has no customer key yet: sign in to the " + "dashboard once, and one is made for you") } if customerKey, err = unwrapCustomerKey(master, *held.Wrap); err != nil { if held.Stale { return nil, errors.New("your password was changed since your customer key was " + "put under it, so the password no longer opens the key: sign in to the " + "dashboard to bring it back, or give the customer key you saved with --open-with") } return nil, errors.New("your password does not open your customer key") } } defer clear(customerKey) sealed, err := acct.customerSeal(req.store) if err != nil { var r *refusal if errors.As(err, &r) && r.code == "no_customer_slot" { return nil, fmt.Errorf("the store %q is not added to your customer key: open it with "+ "one of its store keys (--open-with), or add it to your customer key in the dashboard", req.store) } if errors.As(err, &r) && r.status == 404 { return nil, fmt.Errorf("there is no store named %q", req.store) } return nil, fmt.Errorf("could not open the store %q: %w", req.store, err) } dek, err := openCustomerSeal(sealed, customerKey, customerID) if err != nil { return nil, fmt.Errorf("your customer key does not open the store %q: it is sealed to an "+ "earlier customer key. Open it with one of its store keys (--open-with), or add it to "+ "your customer key in the dashboard", req.store) } return dek, nil } func explainSignIn(err error) error { var r *refusal if errors.As(err, &r) && r.status == 401 { return errors.New("could not sign in: wrong email or password") } return fmt.Errorf("could not sign in: %w", err) } func explainMint(err error, req request) error { var r *refusal if errors.As(err, &r) && r.status == 404 { return fmt.Errorf("there is no store named %q", req.store) } return fmt.Errorf("the key was not made: %w", err) }