// SPDX-License-Identifier: MIT package main import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "sync" "testing" ) // fakeAccount answers the six calls this tool makes, the way the account // service answers them. It holds what the service holds and no more: hashes, // slots and seals. It checks every slot it is handed by opening it, which the // real service cannot do, so a test learns when a key would have opened // nothing. type fakeAccount struct { t *testing.T mu sync.Mutex customerID string email string proof string wrap *string stale bool sessions map[string]bool tokens map[string]string // token -> the highest level it may make stores map[string]*fakeStore retireFmt bool redirectTo string // when set, answer with a redirect there instead redirectAt string // ...for this path only, or for every path when empty redirectAs int seen []string // every request line, header and body received signedOut int minted []map[string]any } type fakeStore struct { dek []byte seal string // the store's seal to the customer key, if any slots map[string]string // key hash -> slot label map[string]bool } var levelRank = map[string]int{"read": 0, "read-add": 1, "read-write": 2, "read-write-delete": 3} func newFake(t *testing.T) (*fakeAccount, *httptest.Server) { v := loadVectors(t) f := &fakeAccount{ t: t, customerID: v.CustomerSeal.Salt, email: v.Password.NormalizedEmail, proof: v.Password.LoginProofHex, wrap: &v.Password.CustomerKeyWrapB64, sessions: map[string]bool{}, tokens: map[string]string{}, stores: map[string]*fakeStore{}, } srv := httptest.NewServer(http.HandlerFunc(f.serve)) t.Cleanup(srv.Close) return f, srv } // addStore makes a store sealed to the vectors' customer key, with one store // key, and answers that key. func (f *fakeAccount) addStore(name string, sealed bool) string { f.t.Helper() dek := make([]byte, keyLen) rand.Read(dek) s := &fakeStore{dek: dek, slots: map[string]string{}, label: map[string]bool{"first": true}} if sealed { ck, _ := parseCustomerKey(loadVectors(f.t).Password.CustomerKeyText) kek, _ := deriveKey(ck, []byte(f.customerID), customerSealInfo) s.seal, _ = seal(kek, dek) } key, _ := newStoreKey() s.slots[keyHash(key)], _ = makeSlot(dek, key, f.customerID) f.stores[name] = s return key } func (f *fakeAccount) answer(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(body) } func (f *fakeAccount) refuse(w http.ResponseWriter, status int, msg, code string) { body := map[string]string{"error": msg} if code != "" { body["code"] = code } f.answer(w, status, body) } func (f *fakeAccount) serve(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() raw, _ := io.ReadAll(r.Body) var hdr bytes.Buffer r.Header.Write(&hdr) f.seen = append(f.seen, r.Method+" "+r.URL.EscapedPath()+"\n"+hdr.String()+"\n"+string(raw)) if r.Header.Get("Origin") != "" { f.t.Errorf("%s %s sent an Origin header", r.Method, r.URL.Path) } if f.redirectTo != "" && (f.redirectAt == "" || f.redirectAt == r.URL.Path) { w.Header().Set("Location", f.redirectTo+r.URL.Path) w.WriteHeader(f.redirectAs) return } var body map[string]any json.Unmarshal(raw, &body) str := func(k string) string { s, _ := body[k].(string); return s } if r.Method == "POST" && r.URL.Path == "/v1/login" { if str("email") != f.email || str("login_proof") != f.proof { f.refuse(w, 401, "invalid credentials", "") return } sid := make([]byte, 16) rand.Read(sid) session := hex.EncodeToString(sid) f.sessions[session] = true http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: session, HttpOnly: true}) f.answer(w, 200, map[string]any{"customer_id": f.customerID, "email": f.email}) return } // Who is calling: a bearer wins over a cookie, as at the service. tokenLevel, session := "", "" if bearer, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { lvl, known := f.tokens[bearer] if !known { f.refuse(w, 401, "that account token is not valid", "token_invalid") return } tokenLevel = lvl } else if c, err := r.Cookie(sessionCookie); err == nil && f.sessions[c.Value] { session = c.Value } else { f.refuse(w, 401, "not signed in", "") return } store := "" if rest, ok := strings.CutPrefix(r.URL.Path, "/v1/stores/"); ok { store, _ = strings.CutSuffix(rest, "/customer-slot") } route := r.Method + " " + r.URL.Path tokenRoutes := map[string]bool{"GET /v1/token": true, "POST /v1/keys/slot": true, "POST /v1/keys/minted": true, "GET /v1/stores/" + store + "/customer-slot": true} if tokenLevel != "" && !tokenRoutes[route] { f.refuse(w, 403, "an account token is not accepted here", "token_scope") return } switch { case route == "POST /v1/logout": delete(f.sessions, session) f.signedOut++ w.WriteHeader(204) case route == "GET /v1/token": f.answer(w, 200, map[string]any{"id": "tok1", "label": "ci", "level": tokenLevel, "customer_id": f.customerID}) case route == "GET /v1/customer-key": f.answer(w, 200, map[string]any{"customer_key_wrap": f.wrap, "customer_key_stale": f.stale}) case route == "POST /v1/keys/slot": s := f.stores[str("store")] if s == nil || s.slots[str("key_hash")] == "" { f.refuse(w, 404, "that key doesn't open this store", "") return } f.answer(w, 200, map[string]any{"wrapped_slot": s.slots[str("key_hash")]}) case r.Method == "GET" && store != "": s := f.stores[store] switch { case s == nil: f.refuse(w, 404, "no such store", "no_such_store") case s.seal == "": f.refuse(w, 404, "the store is not sealed to the customer key", "no_customer_slot") default: f.answer(w, 200, map[string]any{"name": store, "customer_slot": s.seal}) } case route == "POST /v1/keys/minted": f.minted = append(f.minted, body) if str("slot_format") != slotFormat || f.retireFmt { f.refuse(w, 400, "this key was made in a format the service no longer opens — "+ "update the key tool, or reload the page, and make the key again", "slot_format_retired") return } if create, _ := body["create"].(bool); create || body["create"] == nil { f.t.Errorf("a mint did not say create=false: %v", body["create"]) } level := str("level") if tokenLevel != "" && levelRank[level] > levelRank[tokenLevel] { f.refuse(w, 403, "this token may make keys up to "+tokenLevel+" — ask for that or less", "token_level") return } s := f.stores[str("store")] if s == nil { f.refuse(w, 404, "no such store", "") return } if s.label[str("label")] { f.refuse(w, 409, "another key already has that name — choose another", "label_in_use") return } s.slots[str("key_hash")] = str("wrapped_slot") s.label[str("label")] = true f.answer(w, 200, map[string]any{"id": "c1", "store": str("store"), "level": level, "status": "active"}) default: f.refuse(w, 404, "not found", "") } } // scratchDir is a directory for one test's files, removed when the test ends. // The removal is best effort, unlike t.TempDir's: these tests are about keys, // and a host that cannot remove a directory the way t.TempDir insists on // should not fail one. func scratchDir(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("", "dkekey-test-") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) return dir } // result is one run of the program. type result struct { code int stdout, stderr string } func runWith(t *testing.T, args []string, envVars map[string]string, stdin string) result { t.Helper() var out, errOut bytes.Buffer code := run(args, environ{ getenv: func(k string) string { return envVars[k] }, stdin: strings.NewReader(stdin), stdout: &out, stderr: &errOut, password: func(string) (string, error) { return loadVectors(t).Password.Password, nil }, }) return result{code, out.String(), errOut.String()} } // newKeyOpens checks the key a run printed was registered, and opens the // store it was made for. func (f *fakeAccount) newKeyOpens(t *testing.T, store, key string) { t.Helper() f.mu.Lock() defer f.mu.Unlock() s := f.stores[store] wrapped := s.slots[keyHash(key)] if wrapped == "" { t.Fatalf("the key printed is not the key registered") } dek, err := openSlot(wrapped, key, f.customerID) if err != nil || !bytes.Equal(dek, s.dek) { t.Fatalf("the new key does not open the store: %v", err) } for _, req := range f.seen { if strings.Contains(req, key) { t.Fatalf("the new key was sent to the account service:\n%s", req) } } } func onlyAKey(t *testing.T, r result) string { t.Helper() if r.code != exitMade { t.Fatalf("exit %d: %s", r.code, r.stderr) } key := strings.TrimSuffix(r.stdout, "\n") if strings.Contains(key, "\n") || !strings.HasPrefix(key, storeKeyPrefix) { t.Fatalf("standard output is not one key: %q", r.stdout) } return key } func TestAPasswordSignInMakesAKeyWithTheCustomerKeyAndSignsOut(t *testing.T) { f, srv := newFake(t) f.addStore("notes", true) r := runWith(t, []string{"mint", "--api", srv.URL, "--store", "notes", "--label", "laptop", "--email", loadVectors(t).Password.Email}, nil, "") key := onlyAKey(t, r) f.newKeyOpens(t, "notes", key) if f.minted[0]["level"] != "read" || f.minted[0]["slot_format"] != slotFormat { t.Fatalf("mint was %v", f.minted[0]) } if f.signedOut != 1 || len(f.sessions) != 0 { t.Fatal("the session was left open") } if !strings.Contains(r.stderr, `"laptop"`) || !strings.Contains(r.stderr, "shown once") { t.Fatalf("stderr: %s", r.stderr) } } func TestAPasswordCanComeOnStandardInput(t *testing.T) { f, srv := newFake(t) f.addStore("notes", true) v := loadVectors(t).Password r := runWith(t, []string{"mint", "--api", srv.URL, "--store", "notes", "--label", "ci", "--email", v.Email, "--password-stdin", "--level", "read-write"}, nil, v.Password+"\n") f.newKeyOpens(t, "notes", onlyAKey(t, r)) if f.minted[0]["level"] != "read-write" { t.Fatalf("level %v", f.minted[0]["level"]) } } func TestATokenMakesAKeyWithAStoreKeyFromTheEnvironment(t *testing.T) { f, srv := newFake(t) opener := f.addStore("notes", false) f.tokens["dke_a_tok"] = "read-write" r := runWith(t, []string{"mint", "--api", srv.URL, "--store", "notes", "--label", "agent", "--level", "read-add"}, map[string]string{"DKEKEY_TOKEN": "dke_a_tok", "DKEKEY_OPEN_KEY": opener}, "") f.newKeyOpens(t, "notes", onlyAKey(t, r)) for _, req := range f.seen { if strings.Contains(req, opener) { t.Fatal("the key that opened the store was sent") } } } func TestATokenFileAndACustomerKeyFileMakeAKey(t *testing.T) { f, srv := newFake(t) f.addStore("notes", true) f.tokens["dke_a_tok"] = "read" dir := scratchDir(t) tok, ck := filepath.Join(dir, "token"), filepath.Join(dir, "ck") os.WriteFile(tok, []byte("dke_a_tok\n"), 0o600) os.WriteFile(ck, []byte(loadVectors(t).Password.CustomerKeyText+"\n"), 0o600) r := runWith(t, []string{"mint", "--api", srv.URL, "--store", "notes", "--label", "agent", "--token-file", tok, "--open-with", ck}, nil, "") f.newKeyOpens(t, "notes", onlyAKey(t, r)) } func TestTheKeyCanGoToAFileThatDoesNotExistYet(t *testing.T) { f, srv := newFake(t) f.addStore("notes", true) out := filepath.Join(scratchDir(t), "key") r := runWith(t, []string{"mint", "--api", srv.URL, "--store", "notes", "--label", "laptop", "--email", loadVectors(t).Password.Email, "--out", out}, nil, "") if r.code != exitMade || r.stdout != "" { t.Fatalf("exit %d, stdout %q: %s", r.code, r.stdout, r.stderr) } st, err := os.Stat(out) if err != nil { t.Fatal(err) } // Windows keeps no mode bits: there the file takes its folder's access // rules, as the README says. if runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 { t.Fatalf("key file mode %v", st.Mode().Perm()) } b, _ := os.ReadFile(out) f.newKeyOpens(t, "notes", strings.TrimSpace(string(b))) } func TestAFileAlreadyThereStopsTheMintBeforeAnythingIsSent(t *testing.T) { f, srv := newFake(t) f.addStore("notes", true) out := filepath.Join(scratchDir(t), "key") os.WriteFile(out, []byte("an earlier key\n"), 0o600) r := runWith(t, []string{"mint", "--api", srv.URL, "--store", "notes", "--label", "laptop", "--email", loadVectors(t).Password.Email, "--out", out}, nil, "") if r.code != exitFailed || len(f.seen) != 0 { t.Fatalf("exit %d after %d requests", r.code, len(f.seen)) } if b, _ := os.ReadFile(out); string(b) != "an earlier key\n" { t.Fatal("the file was overwritten") } } func TestAFailedMintLeavesNoKeyFileBehind(t *testing.T) { f, srv := newFake(t) f.addStore("notes", true) out := filepath.Join(scratchDir(t), "key") r := runWith(t, []string{"mint", "--api", srv.URL, "--store", "other", "--label", "laptop", "--email", loadVectors(t).Password.Email, "--out", out}, nil, "") if r.code != exitFailed { t.Fatalf("exit %d", r.code) } if _, err := os.Stat(out); !os.IsNotExist(err) { t.Fatal("an empty key file was left behind") } } func TestRefusalsAreSaidAndMakeNoKey(t *testing.T) { v := loadVectors(t).Password cases := []struct { name string setup func(f *fakeAccount) args []string env map[string]string want string }{ {"wrong password", func(f *fakeAccount) { f.proof = "0" }, nil, nil, "wrong email or password"}, {"no store", nil, []string{"--store", "missing"}, nil, `no store named "missing"`}, {"not sealed", func(f *fakeAccount) { f.stores["notes"].seal = "" }, nil, nil, "not added to your customer key"}, {"earlier customer key", func(f *fakeAccount) { other := make([]byte, keyLen) kek, _ := deriveKey(other, []byte(f.customerID), customerSealInfo) f.stores["notes"].seal, _ = seal(kek, f.stores["notes"].dek) }, nil, nil, "sealed to an earlier customer key"}, {"stale wrap", func(f *fakeAccount) { f.stale = true other := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" f.wrap = &other }, nil, nil, "password was changed"}, {"no customer key", func(f *fakeAccount) { f.wrap = nil }, nil, nil, "no customer key yet"}, {"name taken", nil, []string{"--label", "first"}, nil, "another key already has that name"}, {"format retired", func(f *fakeAccount) { f.retireFmt = true }, nil, nil, "update the key tool"}, {"above the token", func(f *fakeAccount) { f.tokens["dke_a_tok"] = "read" }, []string{"--email", "", "--level", "read-write"}, map[string]string{"DKEKEY_TOKEN": "dke_a_tok", "DKEKEY_OPEN_KEY": "OPENER"}, "this token may make keys up to read"}, {"revoked token", nil, []string{"--email", ""}, map[string]string{"DKEKEY_TOKEN": "dke_a_gone", "DKEKEY_OPEN_KEY": "OPENER"}, "not valid"}, {"a key that is not the store's", nil, []string{"--email", ""}, map[string]string{"DKEKEY_TOKEN": "dke_a_tok", "DKEKEY_OPEN_KEY": "dke_s_unknown"}, "does not open the store"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { f, srv := newFake(t) opener := f.addStore("notes", true) f.tokens["dke_a_tok"] = "read-write" if c.setup != nil { c.setup(f) } env := map[string]string{} for k, val := range c.env { env[k] = strings.ReplaceAll(val, "OPENER", opener) } args := append([]string{"mint", "--api", srv.URL, "--store", "notes", "--label", "new", "--email", v.Email}, c.args...) before := len(f.stores["notes"].slots) r := runWith(t, args, env, "") if r.code != exitFailed || r.stdout != "" || !strings.Contains(r.stderr, c.want) { t.Fatalf("exit %d, stdout %q, stderr %q; want %q", r.code, r.stdout, r.stderr, c.want) } if len(f.stores["notes"].slots) != before { t.Fatal("a key was registered") } }) } } func TestARedirectIsNotFollowedAndCarriesNothingElsewhere(t *testing.T) { // The second server is on the same host as the first, on another port, // which is the case Go would carry the token and the cookie to. A 307 or // 308 would carry the sign-in body to any host. v := loadVectors(t).Password ways := []struct { name string at string // the call that is redirected; "" redirects the first args []string env map[string]string }{ {"signing in with a password", "", []string{"--email", v.Email}, nil}, {"with a session", "/v1/customer-key", []string{"--email", v.Email}, nil}, {"with a token", "", nil, map[string]string{"DKEKEY_TOKEN": "dke_a_tok", "DKEKEY_OPEN_KEY": "OPENER"}}, } for _, way := range ways { for _, status := range []int{301, 302, 303, 307, 308} { t.Run(way.name+"/"+http.StatusText(status), func(t *testing.T) { f, srv := newFake(t) elsewhere, there := newFake(t) opener := f.addStore("notes", true) f.tokens["dke_a_tok"] = "read-write" f.redirectTo, f.redirectAt, f.redirectAs = there.URL, way.at, status env := map[string]string{} for k, val := range way.env { env[k] = strings.ReplaceAll(val, "OPENER", opener) } args := append([]string{"mint", "--api", srv.URL, "--store", "notes", "--label", "new"}, way.args...) r := runWith(t, args, env, "") if len(elsewhere.seen) != 0 { t.Fatalf("a %d carried a request to the address it named:\n%s", status, strings.Join(elsewhere.seen, "\n")) } if r.code != exitFailed || r.stdout != "" || !strings.Contains(r.stderr, "redirect") || !strings.Contains(r.stderr, there.URL) { t.Fatalf("exit %d, stdout %q, stderr %q", r.code, r.stdout, r.stderr) } if len(f.stores["notes"].slots) != 1 { t.Fatal("a key was registered") } }) } } } func TestACommandLineThatCannotBeRightIsRefusedBeforeAnythingIsSent(t *testing.T) { dir := scratchDir(t) keyFile := filepath.Join(dir, "k") os.WriteFile(keyFile, []byte("not a key\n"), 0o600) emptyFile := filepath.Join(dir, "empty") os.WriteFile(emptyFile, []byte("\n"), 0o600) tok := map[string]string{"DKEKEY_TOKEN": "dke_a_tok"} cases := []struct { name string args []string env map[string]string want string }{ {"no store", []string{"--label", "a", "--email", "e"}, nil, "--store is required"}, {"no label", []string{"--store", "s", "--email", "e"}, nil, "--label is required"}, {"bad level", []string{"--store", "s", "--label", "a", "--email", "e", "--level", "write"}, nil, "--level must be one of"}, {"no sign-in", []string{"--store", "s", "--label", "a"}, nil, "sign in with --email"}, {"two sign-ins", []string{"--store", "s", "--label", "a", "--email", "e"}, tok, "not both"}, {"token alone", []string{"--store", "s", "--label", "a"}, tok, "opens no store"}, {"a key as an argument", []string{"--store", "s", "--label", "a", "--open-with", "dke_s_x"}, tok, "takes a FILE"}, {"a token as an argument", []string{"--store", "s", "--label", "a", "--token-file", "dke_a_x"}, nil, "takes a FILE"}, {"not a token", []string{"--store", "s", "--label", "a"}, map[string]string{"DKEKEY_TOKEN": "dke_s_x", "DKEKEY_OPEN_KEY": "dke_s_y"}, "not an account token"}, {"not a key", []string{"--store", "s", "--label", "a", "--open-with", keyFile}, tok, "must be one of its store keys"}, {"stdin twice", []string{"--store", "s", "--label", "a", "--email", "e", "--password-stdin", "--open-with", "-"}, nil, "cannot both read standard input"}, {"stdin password without an email", []string{"--store", "s", "--label", "a", "--password-stdin"}, tok, "--password-stdin needs --email"}, {"an empty key file", []string{"--store", "s", "--label", "a", "--open-with", emptyFile}, tok, "is empty"}, {"an empty token file", []string{"--store", "s", "--label", "a", "--token-file", emptyFile}, nil, "is empty"}, {"a stray word", []string{"--store", "s", "--label", "a", "--email", "e", "extra"}, nil, `unexpected "extra"`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { f, srv := newFake(t) r := runWith(t, append([]string{"mint", "--api", srv.URL}, c.args...), c.env, "") if r.code != exitUsage || !strings.Contains(r.stderr, c.want) { t.Fatalf("exit %d, stderr %q; want %q", r.code, r.stderr, c.want) } if len(f.seen) != 0 { t.Fatal("a request was sent") } }) } } func TestVersionNamesTheFormatAndHelpIsOnStandardOutput(t *testing.T) { r := runWith(t, []string{"version"}, nil, "") if r.code != 0 || r.stdout != "dkekey "+version+" (key format "+slotFormat+")\n" { t.Fatalf("%d %q", r.code, r.stdout) } for _, args := range [][]string{{"help"}, {"--help"}, {"mint", "--help"}} { r := runWith(t, args, nil, "") if !strings.HasPrefix(r.stdout, "dkekey makes DKE store keys") { t.Fatalf("%v: %q", args, r.stdout) } } if r := runWith(t, []string{"frob"}, nil, ""); r.code != exitUsage { t.Fatalf("unknown command exit %d", r.code) } if r := runWith(t, nil, nil, ""); r.code != exitUsage { t.Fatalf("no command exit %d", r.code) } } func TestTheStoreNameIsOneSegmentOfThePath(t *testing.T) { f, srv := newFake(t) r := runWith(t, []string{"mint", "--api", srv.URL, "--store", "a/b", "--label", "x", "--email", loadVectors(t).Password.Email}, nil, "") if r.code != exitFailed { t.Fatalf("exit %d", r.code) } asked := false for _, req := range f.seen { if strings.HasPrefix(req, "GET /v1/stores/") { asked = true if !strings.HasPrefix(req, "GET /v1/stores/a%2Fb/customer-slot\n") { t.Fatalf("store path: %s", strings.SplitN(req, "\n", 2)[0]) } } } if !asked { t.Fatal("the store was never asked for") } }