// SPDX-License-Identifier: MIT package main import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) // defaultAPI is the account service the dashboard itself calls. const defaultAPI = "https://dke.langsyn.com/api/account" const sessionCookie = "ls_session" // account is a signed-in client of the account service. It holds one of two // credentials: the session a password sign-in starts, or an account token. // It never holds a key that opens a store; the calls below send hashes and // wraps only. type account struct { base string client *http.Client session string token string } // refusal is the account service saying no, with the code it named. type refusal struct { status int code string message string } func (r *refusal) Error() string { if r.message != "" { return r.message } return fmt.Sprintf("the account service answered %d", r.status) } func newAccount(base string) *account { return &account{ base: strings.TrimRight(base, "/"), client: &http.Client{ Timeout: 60 * time.Second, // A redirect is answered, never followed. Go would follow it with // the token and the session cookie whenever the new address has // the same host name, whatever its port or scheme, and on a 307 or // 308 it sends the sign-in body, login proof included, to any // host at all. The account service never redirects a call. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, } } // call sends one request. A body is sent as JSON, and a JSON answer is read // into out. Any answer outside 2xx is a *refusal. func (a *account) call(method, path string, body, out any) (*http.Response, error) { var rd io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return nil, err } rd = bytes.NewReader(b) } req, err := http.NewRequest(method, a.base+path, rd) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "dkekey/"+version) if a.token != "" { req.Header.Set("Authorization", "Bearer "+a.token) } if a.session != "" { req.AddCookie(&http.Cookie{Name: sessionCookie, Value: a.session}) } res, err := a.client.Do(req) if err != nil { return nil, fmt.Errorf("could not reach the account service: %w", err) } defer res.Body.Close() raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("could not read the account service's answer: %w", err) } if res.StatusCode >= 300 && res.StatusCode <= 399 { where := "" if loc := res.Header.Get("Location"); loc != "" { where = " to " + loc } return res, &refusal{status: res.StatusCode, message: fmt.Sprintf( "the account service at %s answered with a redirect%s, which is not followed: "+ "check the address", a.base, where)} } if res.StatusCode < 200 || res.StatusCode > 299 { r := &refusal{status: res.StatusCode} var e struct { Error string `json:"error"` Code string `json:"code"` } if json.Unmarshal(raw, &e) == nil { r.message, r.code = e.Error, e.Code } return res, r } if out != nil { if err := json.Unmarshal(raw, out); err != nil { return res, fmt.Errorf("the account service answered in a form this tool does not read: %w", err) } } return res, nil } // signIn starts a session with a login proof. It answers the account's // customer id. func (a *account) signIn(email, proof string) (string, error) { var me struct { CustomerID string `json:"customer_id"` } res, err := a.call("POST", "/v1/login", map[string]string{"email": normEmail(email), "login_proof": proof}, &me) if err != nil { return "", err } for _, c := range res.Cookies() { if c.Name == sessionCookie && c.Value != "" { a.session = c.Value } } if a.session == "" || me.CustomerID == "" { return "", errors.New("the account service signed in without starting a session") } return me.CustomerID, nil } // signOut ends the session signIn started. A session left behind would end on // its own; this only makes it end sooner. func (a *account) signOut() { if a.session == "" { return } _, _ = a.call("POST", "/v1/logout", nil, nil) a.session = "" } // tokenInfo is what an account token says about itself. type tokenInfo struct { ID string `json:"id"` Label string `json:"label"` Level string `json:"level"` CustomerID string `json:"customer_id"` } func (a *account) whoAmI() (tokenInfo, error) { var t tokenInfo _, err := a.call("GET", "/v1/token", nil, &t) if err == nil && t.CustomerID == "" { err = errors.New("the account service did not say whose token this is") } return t, err } // customerKeyWrap is the account's customer key, wrapped under its password. type customerKeyWrap struct { Wrap *string `json:"customer_key_wrap"` Stale bool `json:"customer_key_stale"` } func (a *account) customerKey() (customerKeyWrap, error) { var w customerKeyWrap _, err := a.call("GET", "/v1/customer-key", nil, &w) return w, err } // slot fetches the slot a store key opens, by the key's hash. func (a *account) slot(store, hash string) (string, error) { var s struct { WrappedSlot string `json:"wrapped_slot"` } _, err := a.call("POST", "/v1/keys/slot", map[string]string{"store": store, "key_hash": hash}, &s) if err == nil && s.WrappedSlot == "" { err = errors.New("the account service answered with no slot") } return s.WrappedSlot, err } // customerSeal fetches the store's seal to the customer key. func (a *account) customerSeal(store string) (string, error) { var s struct { CustomerSlot string `json:"customer_slot"` } _, err := a.call("GET", "/v1/stores/"+url.PathEscape(store)+"/customer-slot", nil, &s) if err == nil && s.CustomerSlot == "" { err = errors.New("the account service answered with no seal") } return s.CustomerSlot, err } // minted is what the account service says of a key it took. type minted struct { ID string `json:"id"` Store string `json:"store"` Level string `json:"level"` } // addKey hands the account a new key's hash and slot. `create` is false: this // tool adds keys to stores that exist and never makes one. func (a *account) addKey(store, hash, wrapped, level, label string) (minted, error) { var m minted _, err := a.call("POST", "/v1/keys/minted", map[string]any{ "store": store, "key_hash": hash, "wrapped_slot": wrapped, "slot_format": slotFormat, "level": level, "label": label, "create": false, }, &m) return m, err }