diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 5b23c4e..11d2030 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -41,6 +41,7 @@ import ( "echo-lot.app/server/internal/config" "echo-lot.app/server/internal/control" "echo-lot.app/server/internal/dataplane" + "echo-lot.app/server/internal/oidc" "echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/selftest" "echo-lot.app/server/internal/selfupdate" @@ -150,6 +151,27 @@ func serve(cfg *config.Config) error { slog.Info("client compatibility", "accepts_app", appRange.String(), "protocol", control.ProtocolVersion, "schema", control.SchemaVersion) + // Identity is optional. Without an issuer the server simply has no sign-in, and + // uploads=account can never be satisfied — which is the honest outcome, not a silent + // downgrade to anonymous. + var idp *oidc.Verifier + if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" { + idp = oidc.New(oidc.Config{ + Issuer: cfg.OIDCIssuer, + ClientID: cfg.OIDCClientID, + AdminGroup: cfg.OIDCAdminGroup, + }, nil) + slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer, + "client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup) + if cfg.OIDCAdminGroup == "" { + slog.Warn("no admin group set: nobody will be an admin via OIDC " + + "(set ECHOLOT_OIDC_ADMIN_GROUP)") + } + } else if cfg.UploadsMode == string(runs.ModeAccount) { + slog.Warn("uploads=account but no identity provider is configured — " + + "every upload will be refused") + } + ctl := &control.Server{ Store: st, Sessions: sessions, Name: cfg.Name, UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), @@ -161,6 +183,7 @@ func serve(cfg *config.Config) error { Runs: runStore, AppRange: appRange, PublicControlURL: publicControlURL(cfg), + OIDC: idp, } // Left nil when there is no raw socket, so the handler answers "not implemented" with a // reason rather than failing somewhere deeper. diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 5892754..228307f 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -67,6 +67,12 @@ type Config struct { // first control listen address. PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url + // Identity provider. Empty issuer disables sign-in entirely; the server is a relying + // party and never stores passwords. + OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer + OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id + OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group + // Mode Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) } @@ -115,6 +121,9 @@ func Load(args []string) (*Config, *Actions, error) { fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables") fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables") fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict") + fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in") + fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "OpenID Connect client id for this server") + fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC") fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443") fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)") fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded") diff --git a/server/internal/control/control.go b/server/internal/control/control.go index 77a8cb4..9053fe2 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -7,6 +7,7 @@ package control import ( + "context" "crypto/rand" "crypto/sha256" "crypto/tls" @@ -27,6 +28,7 @@ import ( "echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/dataplane" + "echo-lot.app/server/internal/oidc" "echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/session" "echo-lot.app/server/internal/store" @@ -57,6 +59,8 @@ type Server struct { // Granted server->client sends (spec §5). Both consume an asymmetric grant. DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error) BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error) + // OIDC verifies ID tokens when the operator has configured an issuer (may be nil). + OIDC *oidc.Verifier // Runs stores uploaded measurement documents (may be nil: uploads unsupported). Runs *runs.Store // FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil: @@ -162,6 +166,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /v1/runs", gate(s.listRuns)) mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun)) mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun)) + mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount)) + mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount)) + mux.HandleFunc("GET /v1/account", gate(s.accountStatus)) // TODO(spec §5): frag_send, throughput (both build on the same grant machinery) return mux } @@ -618,6 +625,10 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) { // The app needs the upload rules before it offers the switch: whether uploads are // accepted at all, and how much identifying detail it must strip first. "uploads": s.uploadPolicy(), + // What a client needs to start a sign-in, without hard-coding the operator's IdP into + // the app: where to authorize, which client id to use, and whether it is worth offering + // sign-in at all on this server. + "auth": s.authInfo(r.Context()), // What this build speaks, and which app versions it will serve. A client checks the // server side of the same question against its own bounds. "compat": map[string]any{ @@ -714,7 +725,7 @@ func (s *Server) uploadRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read failed"}) return } - meta, err := s.Runs.Put(dev.ID, body) + meta, err := s.Runs.Put(dev.ID, body, dev.LinkedToAccount()) switch { case err == nil: slog.Info("run uploaded", "device", dev.ID, "run", meta.ID, @@ -808,3 +819,107 @@ func upstreamJSON(sess *session.Session) map[string]any { "span_ms": u.SpanMs(), "kbps": u.Kbps(), } } + +// authInfo advertises the sign-in configuration, so the app can present a Sign in button only +// when there is something behind it, and can drive the flow without the user typing an issuer URL. +func (s *Server) authInfo(ctx context.Context) map[string]any { + if s.OIDC == nil || !s.OIDC.Config().Enabled() { + return map[string]any{"enabled": false} + } + cfg := s.OIDC.Config() + out := map[string]any{ + "enabled": true, + "issuer": cfg.Issuer, + "client_id": cfg.ClientID, + // The app is a public client on a phone: no secret can be kept, so PKCE is what + // protects the code exchange (RFC 7636), and the redirect comes back through the + // scheme the app already registers for enrollment links. + "flow": "authorization_code+pkce", + "redirect_uri": "echolot://auth", + "scopes": "openid profile email", + } + if d, err := s.OIDC.Discover(ctx); err == nil { + out["authorization_endpoint"] = d.AuthorizationEndpoint + out["token_endpoint"] = d.TokenEndpoint + out["end_session_endpoint"] = d.EndSessionEndpoint + } else { + // Reported rather than hidden: an unreachable IdP is the operator's problem to see, and + // a client that knows the difference can say "sign-in is configured but the provider is + // not answering" instead of failing obscurely. + out["discovery_error"] = err.Error() + } + return out +} + +// linkAccount ties the calling device to the person whose ID token it presents. +// +// The device credential proves *which device*; the ID token proves *which person*. Both are +// required, and neither substitutes for the other: enrollment admits a device to the server, +// signing in attributes it to someone. +func (s *Server) linkAccount(w http.ResponseWriter, r *http.Request) { + dev := s.Store.DeviceByCredential(bearer(r)) + if dev == nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) + return + } + if s.OIDC == nil || !s.OIDC.Config().Enabled() { + writeJSON(w, http.StatusNotImplemented, map[string]string{ + "error": "this server has no identity provider configured, so there is nothing to sign in to", + }) + return + } + var body struct { + IDToken string `json:"id_token"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.IDToken == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected an id_token"}) + return + } + claims, err := s.OIDC.Verify(r.Context(), body.IDToken) + if err != nil { + // Deliberately terse to the client and detailed in the log: a caller probing token + // handling should not be told which check it failed. + slog.Info("rejected sign-in", "device", dev.ID, "err", err) + writeJSON(w, http.StatusForbidden, map[string]string{"error": "the identity token was not accepted"}) + return + } + if err := s.Store.LinkAccount(dev.ID, claims.AccountID(), claims.Display()); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + slog.Info("device linked to account", "device", dev.ID, "account", claims.AccountID(), + "admin", s.OIDC.IsAdmin(claims)) + writeJSON(w, http.StatusOK, map[string]any{ + "account_id": claims.AccountID(), "display_name": claims.Display(), + "admin": s.OIDC.IsAdmin(claims), + }) +} + +// unlinkAccount signs out on this device. The device stays enrolled: signing out should not cost +// someone their enrollment, which an operator had to grant. +func (s *Server) unlinkAccount(w http.ResponseWriter, r *http.Request) { + dev := s.Store.DeviceByCredential(bearer(r)) + if dev == nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) + return + } + if err := s.Store.LinkAccount(dev.ID, "", ""); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) accountStatus(w http.ResponseWriter, r *http.Request) { + dev := s.Store.DeviceByCredential(bearer(r)) + if dev == nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "signed_in": dev.LinkedToAccount(), + "account_id": dev.AccountID, + "display_name": dev.AccountName, + "device_id": dev.ID, + }) +} diff --git a/server/internal/oidc/oidc.go b/server/internal/oidc/oidc.go new file mode 100644 index 0000000..608b9f9 --- /dev/null +++ b/server/internal/oidc/oidc.go @@ -0,0 +1,475 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package oidc verifies OpenID Connect ID tokens against a configured issuer. +// +// Echolot is a *relying party*, never an identity provider. It delegates to whatever IdP the +// operator already runs and stores no passwords — no hashing, no reset flow, no lockout policy, +// and no credential database to leak. For a tool people self-host on a box they also use for +// other things, that is the difference between "one more service" and "one more thing that can +// lose your users' passwords". +// +// Verification is written against the stdlib rather than a JWT library, because the server has no +// external dependencies by design. That is a real constraint and it cuts both ways: the code below +// is longer than `jwt.Parse`, but it is also auditable in one sitting and cannot be broken by +// somebody else's release. The algorithm allow-list is the part that matters — accepting `alg` +// from the token itself is the classic JWT forgery, so it is fixed here and `none` can never +// appear. +package oidc + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "strings" + "sync" + "time" +) + +// Claims are the parts of an ID token Echolot acts on. +type Claims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience audience `json:"aud"` + Expiry int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Nonce string `json:"nonce"` + Email string `json:"email"` + Name string `json:"name"` + Username string `json:"preferred_username"` + Groups []string `json:"groups"` +} + +// AccountID is the stable identity of a person: issuer plus subject. +// +// Subject alone is not enough — it is only unique within an issuer — and email is not stable, +// since people change them and IdPs allow reuse. Keying on iss+sub means an operator can switch +// IdPs and know that the accounts did not silently merge. +func (c Claims) AccountID() string { return c.Issuer + "#" + c.Subject } + +// Display is the friendliest name available, for the admin UI. +func (c Claims) Display() string { + for _, s := range []string{c.Name, c.Username, c.Email} { + if s != "" { + return s + } + } + return c.Subject +} + +// audience tolerates the spec's two shapes: a string or an array of strings. +type audience []string + +func (a *audience) UnmarshalJSON(b []byte) error { + var one string + if err := json.Unmarshal(b, &one); err == nil { + *a = audience{one} + return nil + } + var many []string + if err := json.Unmarshal(b, &many); err != nil { + return err + } + *a = many + return nil +} + +func (a audience) contains(s string) bool { + for _, v := range a { + if v == s { + return true + } + } + return false +} + +// Config is what the operator supplies. +type Config struct { + // Issuer is the IdP's base URL, e.g. https://auth.example.net/application/o/echolot/ + Issuer string + // ClientID is this server's registered client. Tokens must be addressed to it. + ClientID string + // AdminGroup, when set, is the group claim a person must hold to reach the admin UI. + // Empty means no one is an admin via OIDC, which is the safe default: an operator who has + // not said who may administer the server has not said "everyone". + AdminGroup string + // Skew tolerated on exp/iat, for ordinary clock drift between the IdP and this server. + Skew time.Duration +} + +func (c Config) Enabled() bool { return c.Issuer != "" && c.ClientID != "" } + +// Discovery is the subset of the provider metadata document that is used. +type Discovery struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + UserinfoEndpoint string `json:"userinfo_endpoint"` + EndSessionEndpoint string `json:"end_session_endpoint"` +} + +// Verifier fetches provider metadata and keys, and checks tokens against them. +type Verifier struct { + cfg Config + client *http.Client + + mu sync.RWMutex + discovery *Discovery + keys map[string]crypto.PublicKey + keysAt time.Time +} + +func New(cfg Config, client *http.Client) *Verifier { + if cfg.Skew == 0 { + cfg.Skew = 2 * time.Minute + } + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + return &Verifier{cfg: cfg, client: client, keys: map[string]crypto.PublicKey{}} +} + +func (v *Verifier) Config() Config { return v.cfg } + +var ( + ErrDisabled = errors.New("no OIDC issuer is configured on this server") + ErrMalformed = errors.New("token is not a well-formed JWT") + ErrSignature = errors.New("token signature does not verify") + ErrClaims = errors.New("token claims are not acceptable") +) + +// Discover fetches (and caches) the provider metadata. +func (v *Verifier) Discover(ctx context.Context) (*Discovery, error) { + if !v.cfg.Enabled() { + return nil, ErrDisabled + } + v.mu.RLock() + d := v.discovery + v.mu.RUnlock() + if d != nil { + return d, nil + } + + url := strings.TrimRight(v.cfg.Issuer, "/") + "/.well-known/openid-configuration" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := v.client.Do(req) + if err != nil { + return nil, fmt.Errorf("discovery: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("discovery: %s returned %d", url, resp.StatusCode) + } + var got Discovery + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + return nil, fmt.Errorf("discovery: %w", err) + } + // The issuer in the document must match the one configured, or a redirect could point us at + // somebody else's keys while we keep believing we are talking to the configured provider. + if strings.TrimRight(got.Issuer, "/") != strings.TrimRight(v.cfg.Issuer, "/") { + return nil, fmt.Errorf("discovery: document says issuer %q, configured %q", got.Issuer, v.cfg.Issuer) + } + v.mu.Lock() + v.discovery = &got + v.mu.Unlock() + return &got, nil +} + +// jwksTTL is how long keys are trusted before refetching. Short enough to pick up a rotation +// without an operator restarting anything; long enough that token checks are not IdP round trips. +const jwksTTL = 15 * time.Minute + +func (v *Verifier) keyFor(ctx context.Context, kid string) (crypto.PublicKey, error) { + v.mu.RLock() + k, ok := v.keys[kid] + fresh := time.Since(v.keysAt) < jwksTTL + v.mu.RUnlock() + if ok && fresh { + return k, nil + } + if err := v.refreshKeys(ctx); err != nil { + return nil, err + } + v.mu.RLock() + defer v.mu.RUnlock() + if k, ok := v.keys[kid]; ok { + return k, nil + } + // A kid we have never seen, after a refresh, is a token from somewhere else. + return nil, fmt.Errorf("%w: no key %q at the issuer", ErrSignature, kid) +} + +func (v *Verifier) refreshKeys(ctx context.Context) error { + d, err := v.Discover(ctx) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.JWKSURI, nil) + if err != nil { + return err + } + resp, err := v.client.Do(req) + if err != nil { + return fmt.Errorf("jwks: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("jwks: %s returned %d", d.JWKSURI, resp.StatusCode) + } + var set struct { + Keys []jwk `json:"keys"` + } + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + return fmt.Errorf("jwks: %w", err) + } + parsed := make(map[string]crypto.PublicKey, len(set.Keys)) + for _, k := range set.Keys { + if pub, err := k.publicKey(); err == nil { + parsed[k.Kid] = pub + } + } + if len(parsed) == 0 { + return errors.New("jwks: no usable keys") + } + v.mu.Lock() + v.keys = parsed + v.keysAt = time.Now() + v.mu.Unlock() + return nil +} + +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Alg string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +func (k jwk) publicKey() (crypto.PublicKey, error) { + switch k.Kty { + case "RSA": + n, err := b64uint(k.N) + if err != nil { + return nil, err + } + e, err := b64uint(k.E) + if err != nil { + return nil, err + } + if !e.IsInt64() || e.Int64() > 1<<31 { + return nil, errors.New("implausible RSA exponent") + } + return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil + case "EC": + curve, err := curveFor(k.Crv) + if err != nil { + return nil, err + } + x, err := b64uint(k.X) + if err != nil { + return nil, err + } + y, err := b64uint(k.Y) + if err != nil { + return nil, err + } + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil + } + return nil, fmt.Errorf("unsupported key type %q", k.Kty) +} + +// Verify checks a serialized ID token and returns its claims. +// +// The order is deliberate: structure, then algorithm, then signature, then claims. Nothing about +// the token's contents is believed before its signature has been checked — reading `iss` or `aud` +// out of an unverified token and acting on it is how "verified" tokens turn out not to be. +func (v *Verifier) Verify(ctx context.Context, token string) (*Claims, error) { + if !v.cfg.Enabled() { + return nil, ErrDisabled + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, ErrMalformed + } + headerJSON, err := b64(parts[0]) + if err != nil { + return nil, ErrMalformed + } + var hdr struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + Typ string `json:"typ"` + } + if err := json.Unmarshal(headerJSON, &hdr); err != nil { + return nil, ErrMalformed + } + // The allow-list is fixed here rather than taken from the token. Trusting the token's own + // `alg` is the classic JWT forgery: "none" turns any token into a valid one, and swapping RS256 + // for HS256 lets an attacker sign with the public key. Neither is reachable from here. + if _, ok := allowedAlgs[hdr.Alg]; !ok { + return nil, fmt.Errorf("%w: algorithm %q is not accepted", ErrSignature, hdr.Alg) + } + + pub, err := v.keyFor(ctx, hdr.Kid) + if err != nil { + return nil, err + } + sig, err := b64(parts[2]) + if err != nil { + return nil, ErrMalformed + } + signed := parts[0] + "." + parts[1] + if err := verifySignature(hdr.Alg, pub, []byte(signed), sig); err != nil { + return nil, err + } + + payload, err := b64(parts[1]) + if err != nil { + return nil, ErrMalformed + } + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, ErrMalformed + } + if err := v.checkClaims(claims); err != nil { + return nil, err + } + return &claims, nil +} + +func (v *Verifier) checkClaims(c Claims) error { + if strings.TrimRight(c.Issuer, "/") != strings.TrimRight(v.cfg.Issuer, "/") { + return fmt.Errorf("%w: issued by %q, expected %q", ErrClaims, c.Issuer, v.cfg.Issuer) + } + // A token addressed to a different client is a valid token that was not meant for us — + // accepting it lets any other client of the same IdP authenticate here. + if !c.Audience.contains(v.cfg.ClientID) { + return fmt.Errorf("%w: addressed to %v, not to %q", ErrClaims, []string(c.Audience), v.cfg.ClientID) + } + if c.Subject == "" { + return fmt.Errorf("%w: no subject", ErrClaims) + } + now := time.Now() + if c.Expiry == 0 || now.After(time.Unix(c.Expiry, 0).Add(v.cfg.Skew)) { + return fmt.Errorf("%w: expired", ErrClaims) + } + if c.IssuedAt != 0 && now.Add(v.cfg.Skew).Before(time.Unix(c.IssuedAt, 0)) { + return fmt.Errorf("%w: issued in the future", ErrClaims) + } + return nil +} + +// IsAdmin reports whether these claims carry the configured admin group. +// +// With no group configured nobody is an admin: an operator who has not said who may administer +// the server has not thereby said "anyone who can log in". +func (v *Verifier) IsAdmin(c *Claims) bool { + if c == nil || v.cfg.AdminGroup == "" { + return false + } + for _, g := range c.Groups { + if g == v.cfg.AdminGroup { + return true + } + } + return false +} + +var allowedAlgs = map[string]crypto.Hash{ + "RS256": crypto.SHA256, "RS384": crypto.SHA384, "RS512": crypto.SHA512, + "ES256": crypto.SHA256, "ES384": crypto.SHA384, "ES512": crypto.SHA512, +} + +func verifySignature(alg string, pub crypto.PublicKey, signed, sig []byte) error { + h := allowedAlgs[alg] + digest := hashOf(h, signed) + + switch { + case strings.HasPrefix(alg, "RS"): + k, ok := pub.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("%w: %s token against a non-RSA key", ErrSignature, alg) + } + if err := rsa.VerifyPKCS1v15(k, h, digest, sig); err != nil { + return ErrSignature + } + return nil + case strings.HasPrefix(alg, "ES"): + k, ok := pub.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("%w: %s token against a non-EC key", ErrSignature, alg) + } + // JWS packs ECDSA signatures as r||s, fixed width — not the ASN.1 form ecdsa.Verify + // would otherwise expect. + if len(sig)%2 != 0 { + return ErrSignature + } + half := len(sig) / 2 + r := new(big.Int).SetBytes(sig[:half]) + s := new(big.Int).SetBytes(sig[half:]) + if !ecdsa.Verify(k, digest, r, s) { + return ErrSignature + } + return nil + } + return ErrSignature +} + +func hashOf(h crypto.Hash, b []byte) []byte { + switch h { + case crypto.SHA384: + d := sha512.Sum384(b) + return d[:] + case crypto.SHA512: + d := sha512.Sum512(b) + return d[:] + default: + d := sha256.Sum256(b) + return d[:] + } +} + +func curveFor(crv string) (elliptic.Curve, error) { + switch crv { + case "P-256": + return elliptic.P256(), nil + case "P-384": + return elliptic.P384(), nil + case "P-521": + return elliptic.P521(), nil + } + return nil, fmt.Errorf("unsupported curve %q", crv) +} + +// b64 decodes JWT base64url, which omits padding. +func b64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } + +func b64uint(s string) (*big.Int, error) { + b, err := b64(s) + if err != nil { + return nil, err + } + if len(b) == 0 { + return nil, errors.New("empty value") + } + return new(big.Int).SetBytes(b), nil +} diff --git a/server/internal/oidc/oidc_test.go b/server/internal/oidc/oidc_test.go new file mode 100644 index 0000000..661fa33 --- /dev/null +++ b/server/internal/oidc/oidc_test.go @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package oidc + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// A self-contained IdP: real keys, real signatures, real discovery and JWKS documents. Testing +// token verification against anything less than a genuine signer proves nothing — the failure +// modes that matter here (accepting `none`, accepting another client's token, accepting an +// expired one) all look fine to a mock that just returns success. +type testIdP struct { + *httptest.Server + rsaKey *rsa.PrivateKey + ecKey *ecdsa.PrivateKey +} + +func newIdP(t *testing.T) *testIdP { + t.Helper() + rk, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + ek, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + idp := &testIdP{rsaKey: rk, ecKey: ek} + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(Discovery{ + Issuer: idp.URL, + AuthorizationEndpoint: idp.URL + "/auth", + TokenEndpoint: idp.URL + "/token", + JWKSURI: idp.URL + "/jwks", + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{ + { + "kty": "RSA", "kid": "rsa-1", "alg": "RS256", "use": "sig", + "n": raw(rk.N.Bytes()), + "e": raw(big.NewInt(int64(rk.E)).Bytes()), + }, + { + "kty": "EC", "kid": "ec-1", "alg": "ES256", "use": "sig", "crv": "P-256", + "x": raw(ek.X.Bytes()), "y": raw(ek.Y.Bytes()), + }, + }}) + }) + idp.Server = httptest.NewServer(mux) + t.Cleanup(idp.Close) + return idp +} + +func raw(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func (i *testIdP) sign(t *testing.T, alg, kid string, claims map[string]any) string { + t.Helper() + h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid, "typ": "JWT"}) + p, _ := json.Marshal(claims) + signing := raw(h) + "." + raw(p) + digest := sha256.Sum256([]byte(signing)) + + var sig []byte + switch alg { + case "RS256": + s, err := rsa.SignPKCS1v15(rand.Reader, i.rsaKey, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + sig = s + case "ES256": + r, s, err := ecdsa.Sign(rand.Reader, i.ecKey, digest[:]) + if err != nil { + t.Fatal(err) + } + // JWS wants fixed-width r||s, not ASN.1. + sig = make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + default: + t.Fatalf("unsupported test alg %q", alg) + } + return signing + "." + raw(sig) +} + +func (i *testIdP) claims(extra map[string]any) map[string]any { + c := map[string]any{ + "iss": i.URL, "sub": "user-1", "aud": "echolot", + "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), + "email": "someone@example.net", "groups": []string{"users"}, + } + for k, v := range extra { + c[k] = v + } + return c +} + +func verifier(i *testIdP, adminGroup string) *Verifier { + return New(Config{Issuer: i.URL, ClientID: "echolot", AdminGroup: adminGroup}, i.Client()) +} + +func TestAcceptsAGenuineToken(t *testing.T) { + idp := newIdP(t) + v := verifier(idp, "") + for _, tc := range []struct{ alg, kid string }{{"RS256", "rsa-1"}, {"ES256", "ec-1"}} { + got, err := v.Verify(context.Background(), idp.sign(t, tc.alg, tc.kid, idp.claims(nil))) + if err != nil { + t.Fatalf("%s: %v", tc.alg, err) + } + if got.Subject != "user-1" || got.Email != "someone@example.net" { + t.Fatalf("%s: claims not parsed: %+v", tc.alg, got) + } + if want := idp.URL + "#user-1"; got.AccountID() != want { + t.Errorf("AccountID = %q, want %q", got.AccountID(), want) + } + } +} + +// "alg": "none" is the oldest JWT forgery there is: strip the signature, declare no algorithm, +// and a naive verifier accepts anything. It must not even reach the key lookup. +func TestRejectsAlgNone(t *testing.T) { + idp := newIdP(t) + v := verifier(idp, "") + h, _ := json.Marshal(map[string]string{"alg": "none", "kid": "rsa-1", "typ": "JWT"}) + p, _ := json.Marshal(idp.claims(nil)) + token := raw(h) + "." + raw(p) + "." + if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) { + t.Fatalf("alg=none was not refused as a signature failure: %v", err) + } +} + +// The other classic: declare HS256 so the verifier treats the RSA *public* key as an HMAC secret, +// which the attacker also has. The allow-list has no symmetric algorithms at all. +func TestRejectsSymmetricAlgorithmConfusion(t *testing.T) { + idp := newIdP(t) + v := verifier(idp, "") + h, _ := json.Marshal(map[string]string{"alg": "HS256", "kid": "rsa-1", "typ": "JWT"}) + p, _ := json.Marshal(idp.claims(nil)) + token := raw(h) + "." + raw(p) + "." + raw([]byte("whatever")) + if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) { + t.Fatalf("HS256 confusion was not refused: %v", err) + } +} + +func TestRejectsATamperedPayload(t *testing.T) { + idp := newIdP(t) + v := verifier(idp, "") + good := idp.sign(t, "RS256", "rsa-1", idp.claims(nil)) + // Swap the payload for one claiming to be somebody else, keeping the valid signature. + forged, _ := json.Marshal(idp.claims(map[string]any{"sub": "admin"})) + parts := []byte(good) + dot1, dot2 := 0, 0 + for i, c := range parts { + if c == '.' { + if dot1 == 0 { + dot1 = i + } else { + dot2 = i + } + } + } + token := string(parts[:dot1+1]) + raw(forged) + string(parts[dot2:]) + if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) { + t.Fatalf("a swapped payload was not refused: %v", err) + } +} + +// A token from the same IdP but issued to a different client is perfectly valid — just not for +// us. Accepting it would let any other client of the same provider authenticate here. +func TestRejectsAnotherClientsToken(t *testing.T) { + idp := newIdP(t) + v := verifier(idp, "") + tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": "some-other-app"})) + if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrClaims) { + t.Fatalf("another client's token was accepted: %v", err) + } +} + +func TestAcceptsAudienceArrayContainingUs(t *testing.T) { + idp := newIdP(t) + v := verifier(idp, "") + tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": []string{"other", "echolot"}})) + if _, err := v.Verify(context.Background(), tok); err != nil { + t.Fatalf("an audience array including us was refused: %v", err) + } +} + +func TestRejectsExpiredAndFutureTokens(t *testing.T) { + idp := newIdP(t) + v := verifier(idp, "") + expired := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{ + "exp": time.Now().Add(-time.Hour).Unix(), + })) + if _, err := v.Verify(context.Background(), expired); !errors.Is(err, ErrClaims) { + t.Errorf("expired token accepted: %v", err) + } + future := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{ + "iat": time.Now().Add(time.Hour).Unix(), + })) + if _, err := v.Verify(context.Background(), future); !errors.Is(err, ErrClaims) { + t.Errorf("token issued in the future accepted: %v", err) + } +} + +// A token signed by a completely different provider, with its own keys and its own kid. +func TestRejectsATokenFromAnotherIssuer(t *testing.T) { + ours, theirs := newIdP(t), newIdP(t) + v := verifier(ours, "") + tok := theirs.sign(t, "RS256", "rsa-1", theirs.claims(nil)) + if _, err := v.Verify(context.Background(), tok); err == nil { + t.Fatal("a token from another issuer was accepted") + } +} + +func TestRejectsMalformedTokens(t *testing.T) { + idp := newIdP(t) + v := verifier(idp, "") + for _, bad := range []string{"", "not-a-token", "a.b", "a.b.c.d", "...", "!!!.???.***"} { + if _, err := v.Verify(context.Background(), bad); err == nil { + t.Errorf("%q was accepted", bad) + } + } +} + +// With no admin group configured, nobody is an admin. An operator who has not said who may +// administer the server has not thereby said "anyone who can log in". +func TestNobodyIsAdminUntilAGroupIsConfigured(t *testing.T) { + idp := newIdP(t) + claims := &Claims{Groups: []string{"users", "echolot-admins"}} + + if verifier(idp, "").IsAdmin(claims) { + t.Error("someone was an admin with no admin group configured") + } + if !verifier(idp, "echolot-admins").IsAdmin(claims) { + t.Error("a member of the configured group was not an admin") + } + if verifier(idp, "other-group").IsAdmin(claims) { + t.Error("a non-member was an admin") + } + if verifier(idp, "echolot-admins").IsAdmin(nil) { + t.Error("an absent identity was an admin") + } +} + +// A discovery document whose issuer disagrees with the configured one means we were redirected +// somewhere — and would otherwise have fetched that somewhere's signing keys while believing +// they belonged to the configured provider. +func TestRefusesDiscoveryThatRenamesTheIssuer(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(Discovery{Issuer: "https://somewhere.else", JWKSURI: srv.URL + "/jwks"}) + }) + v := New(Config{Issuer: srv.URL, ClientID: "echolot"}, srv.Client()) + if _, err := v.Discover(context.Background()); err == nil { + t.Fatal("discovery accepted a document for a different issuer") + } +} + +func TestDisabledWithoutConfiguration(t *testing.T) { + v := New(Config{}, nil) + if v.Config().Enabled() { + t.Fatal("an unconfigured verifier reports itself enabled") + } + if _, err := v.Verify(context.Background(), "x.y.z"); !errors.Is(err, ErrDisabled) { + t.Fatalf("want ErrDisabled, got %v", err) + } +} diff --git a/server/internal/runs/runs.go b/server/internal/runs/runs.go index f6541ad..f7e55bb 100644 --- a/server/internal/runs/runs.go +++ b/server/internal/runs/runs.go @@ -38,10 +38,9 @@ const ( // ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already // required an admin-minted token, so "anyone enrolled" is not "anyone". ModeAnonymous Mode = "anonymous" - // ModeAccount accepts uploads only from a device tied to a signed-in account. The account - // system (OIDC) is not built yet, so today this refuses everything with a distinct reason — - // it exists so operators can pick the strict setting now and have it mean the right thing - // when accounts land, rather than silently loosening on upgrade. + // ModeAccount accepts uploads only from a device where somebody has signed in (see + // /v1/account/link). Enrollment alone is not enough: the operator's token admits a device, + // an account attributes it to a person. ModeAccount Mode = "account" ) @@ -120,22 +119,27 @@ func Open(stateDir string, p Policy) (*Store, error) { func (s *Store) Policy() Policy { return s.policy } -// Accepts reports whether an upload would be allowed at all, so callers can answer the -// capability question without a body. -func (s *Store) Accepts() error { +// Accepts reports whether an upload from this caller would be allowed at all, so callers can +// answer the capability question without a body. +// +// linked says whether a person has signed in on the uploading device. It is the only thing that +// distinguishes ModeAccount from ModeOff — and the reason the check takes an argument at all. +func (s *Store) Accepts(linked bool) error { switch s.policy.Mode { case ModeOff: return ErrDisabled case ModeAccount: - return ErrNeedAccount + if !linked { + return ErrNeedAccount + } } return nil } // Put validates and stores one uploaded document. body is the raw JSON as received: it is stored // byte-for-byte so what the device signed off on is what sits on disk. -func (s *Store) Put(deviceID string, body []byte) (Meta, error) { - if err := s.Accepts(); err != nil { +func (s *Store) Put(deviceID string, body []byte, linked bool) (Meta, error) { + if err := s.Accepts(linked); err != nil { return Meta{}, err } if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes { diff --git a/server/internal/runs/runs_test.go b/server/internal/runs/runs_test.go index c0db420..e0810b7 100644 --- a/server/internal/runs/runs_test.go +++ b/server/internal/runs/runs_test.go @@ -34,19 +34,33 @@ func TestModeOffRefusesEverything(t *testing.T) { p := DefaultPolicy() p.Mode = ModeOff s, _ := open(t, p) - if _, err := s.Put("dev1", doc("run-1", AnonFull)); !errors.Is(err, ErrDisabled) { + if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrDisabled) { t.Fatalf("want ErrDisabled, got %v", err) } } -// ModeAccount must refuse today rather than fall back to anonymous: an operator who selects the -// strict setting before accounts exist must not be silently running the permissive one. -func TestModeAccountRefusesUntilAccountsExist(t *testing.T) { +// ModeAccount turns on whether the *caller* has signed in, and nothing else. A device that has +// not is refused with a reason it can act on; one that has is treated exactly like anonymous mode. +func TestModeAccountTurnsOnWhetherTheCallerSignedIn(t *testing.T) { p := DefaultPolicy() p.Mode = ModeAccount s, _ := open(t, p) - if _, err := s.Put("dev1", doc("run-1", AnonFull)); !errors.Is(err, ErrNeedAccount) { - t.Fatalf("want ErrNeedAccount, got %v", err) + + if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrNeedAccount) { + t.Fatalf("an un-signed-in device was not refused: %v", err) + } + if _, err := s.Put("dev1", doc("run-2", AnonFull), true); err != nil { + t.Fatalf("a signed-in device was refused: %v", err) + } +} + +// Signing in must not open a door that the operator closed outright: mode=off means off. +func TestSigningInDoesNotOverrideModeOff(t *testing.T) { + p := DefaultPolicy() + p.Mode = ModeOff + s, _ := open(t, p) + if _, err := s.Put("dev1", doc("run-1", AnonFull), true); !errors.Is(err, ErrDisabled) { + t.Fatalf("a signed-in device uploaded to a server with uploads off: %v", err) } } @@ -55,15 +69,15 @@ func TestMinAnonymizationEnforced(t *testing.T) { p.MinAnonymization = AnonBalanced s, _ := open(t, p) - if _, err := s.Put("dev1", doc("run-full", AnonFull)); !errors.Is(err, ErrNotAnonEnough) { + if _, err := s.Put("dev1", doc("run-full", AnonFull), false); !errors.Is(err, ErrNotAnonEnough) { t.Fatalf("full should be refused when balanced is required, got %v", err) } // An undeclared level means nothing was stripped, so it must be treated as "full". - if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`)); !errors.Is(err, ErrNotAnonEnough) { + if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`), false); !errors.Is(err, ErrNotAnonEnough) { t.Fatalf("undeclared level should be treated as full, got %v", err) } for _, lvl := range []string{AnonBalanced, AnonStrict} { - if _, err := s.Put("dev1", doc("run-"+lvl, lvl)); err != nil { + if _, err := s.Put("dev1", doc("run-"+lvl, lvl), false); err != nil { t.Fatalf("%s should be accepted: %v", lvl, err) } } @@ -74,7 +88,7 @@ func TestSizeLimit(t *testing.T) { p.MaxBytes = 200 s, _ := open(t, p) big := append(doc("run-1", AnonFull), make([]byte, 400)...) - if _, err := s.Put("dev1", big); !errors.Is(err, ErrTooLarge) { + if _, err := s.Put("dev1", big, false); !errors.Is(err, ErrTooLarge) { t.Fatalf("want ErrTooLarge, got %v", err) } } @@ -84,7 +98,7 @@ func TestRetentionByCountKeepsNewest(t *testing.T) { p.MaxRunsPerDevice = 3 s, _ := open(t, p) for i := 0; i < 6; i++ { - if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull)); err != nil { + if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull), false); err != nil { t.Fatalf("put %d: %v", i, err) } time.Sleep(2 * time.Millisecond) // distinct UploadedAt so "newest" is well defined @@ -109,7 +123,7 @@ func TestRetentionByAge(t *testing.T) { p.RetentionDays = 7 p.MaxRunsPerDevice = 0 s, dir := open(t, p) - if _, err := s.Put("dev1", doc("run-old", AnonFull)); err != nil { + if _, err := s.Put("dev1", doc("run-old", AnonFull), false); err != nil { t.Fatal(err) } // Backdate the index entry past the retention window. @@ -123,7 +137,7 @@ func TestRetentionByAge(t *testing.T) { t.Fatal(err) } - if _, err := s.Put("dev1", doc("run-new", AnonFull)); err != nil { + if _, err := s.Put("dev1", doc("run-new", AnonFull), false); err != nil { t.Fatal(err) } got := s.List("dev1") @@ -136,7 +150,7 @@ func TestRetentionByAge(t *testing.T) { // store directory or overwrite another device's data. func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) { s, dir := open(t, DefaultPolicy()) - if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull)); err != nil { + if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull), false); err != nil { t.Fatalf("put: %v", err) } var found []string @@ -159,10 +173,10 @@ func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) { func TestListIsPerDevice(t *testing.T) { s, _ := open(t, DefaultPolicy()) - if _, err := s.Put("devA", doc("run-a", AnonFull)); err != nil { + if _, err := s.Put("devA", doc("run-a", AnonFull), false); err != nil { t.Fatal(err) } - if _, err := s.Put("devB", doc("run-b", AnonFull)); err != nil { + if _, err := s.Put("devB", doc("run-b", AnonFull), false); err != nil { t.Fatal(err) } if got := s.List("devA"); len(got) != 1 || got[0].ID != "run-a" { @@ -175,7 +189,7 @@ func TestListIsPerDevice(t *testing.T) { func TestMetaSummarisesTheDocument(t *testing.T) { s, _ := open(t, DefaultPolicy()) - m, err := s.Put("dev1", doc("run-1", AnonBalanced)) + m, err := s.Put("dev1", doc("run-1", AnonBalanced), false) if err != nil { t.Fatal(err) } @@ -190,7 +204,7 @@ func TestMetaSummarisesTheDocument(t *testing.T) { func TestMalformedRejected(t *testing.T) { s, _ := open(t, DefaultPolicy()) for _, body := range [][]byte{[]byte("not json"), []byte(`{"run":{}}`), []byte(`{}`)} { - if _, err := s.Put("dev1", body); !errors.Is(err, ErrMalformed) { + if _, err := s.Put("dev1", body, false); !errors.Is(err, ErrMalformed) { t.Fatalf("body %q: want ErrMalformed, got %v", body, err) } } diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 2c317fe..e25bf0a 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -37,8 +37,19 @@ type Device struct { Credential string `json:"credential"` Enrolled time.Time `json:"enrolled"` Name string `json:"name,omitempty"` + + // The account this device belongs to, as issuer#subject — empty when nobody has signed in + // on it. Enrollment and sign-in are deliberately separate: a device is admitted by an + // operator's token, and only later (if ever) associated with a person. Servers that accept + // anonymous uploads never need the second step. + AccountID string `json:"account_id,omitempty"` + AccountName string `json:"account_name,omitempty"` + LinkedAt time.Time `json:"linked_at,omitempty"` } +// LinkedToAccount reports whether a person has signed in on this device. +func (d Device) LinkedToAccount() bool { return d.AccountID != "" } + type Store struct { mu sync.Mutex path string @@ -126,6 +137,46 @@ func (s *Store) Redeem(token, name string) (*Device, error) { } // DeviceByCredential authenticates a bearer credential. +// LinkAccount ties a device to a signed-in identity, or clears it when accountID is empty. +func (s *Store) LinkAccount(deviceID, accountID, displayName string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.data.Devices { + if s.data.Devices[i].ID != deviceID { + continue + } + s.data.Devices[i].AccountID = accountID + s.data.Devices[i].AccountName = displayName + if accountID == "" { + s.data.Devices[i].LinkedAt = time.Time{} + } else { + s.data.Devices[i].LinkedAt = time.Now().UTC() + } + return s.save() + } + return errors.New("no such device") +} + +// Devices returns a copy of the device list, for the admin UI. +func (s *Store) Devices() []Device { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Device(nil), s.data.Devices...) +} + +// DeleteDevice revokes a device: its credential stops working immediately. +func (s *Store) DeleteDevice(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.data.Devices { + if s.data.Devices[i].ID == id { + s.data.Devices = append(s.data.Devices[:i], s.data.Devices[i+1:]...) + return s.save() + } + } + return errors.New("no such device") +} + func (s *Store) DeviceByCredential(cred string) *Device { s.mu.Lock() defer s.mu.Unlock()