-
Notifications
You must be signed in to change notification settings - Fork 1k
Added support for end users to create their own BitLocker startup PIN #53095
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b1437d8
20a7765
8611599
868662e
8c93a18
563b253
844b171
83537bc
aeff9d0
1734a76
0bc03c0
5d79690
1befd53
1588cfd
ae63dda
2847203
35211f6
9cc3db4
811751d
d995466
dfd07f2
01a6d11
827802b
dc15a9e
30a6f67
18c9a06
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - Added support for end users to create their own BitLocker startup PIN from the **My device** page, so a Windows user without local admin rights can satisfy a fleet that requires one. The PIN is stored encrypted, handed to the host's agent exactly once, and cleared; Fleet never shows it back to the end user or to an admin. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| package service | ||
|
|
||
| import ( | ||
| "context" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/fleetdm/fleet/v4/pkg/str" | ||
| "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" | ||
| hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" | ||
| "github.com/fleetdm/fleet/v4/server/fleet" | ||
| "github.com/fleetdm/fleet/v4/server/mdm" | ||
| microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" | ||
| ) | ||
|
|
||
| // BitLocker startup PIN handoff. | ||
| // | ||
| // A standard user can't set a startup PIN without UAC elevation, and the browser modal can't reach fleetd, which runs as SYSTEM. | ||
| // So the server stores the PIN encrypted with its private key until the agent collects it once on its next config poll, applies | ||
| // it, and reports back. Only the agent can collect it. An uncollected PIN is cleared by the hourly cleanups cron after its TTL, | ||
| // so the worst case is the TTL plus an hour. | ||
|
|
||
| const ( | ||
| bitLockerPINNotNeededMessage = "This host doesn't need a BitLocker PIN." | ||
| bitLockerPINAgentTooOldMessage = "Fleet's agent on this host is too old to set a BitLocker PIN. Update fleetd and try again." | ||
| bitLockerPINUnreadableError = "Fleet could not read the submitted PIN. Try again." | ||
| ) | ||
|
|
||
| // bitLockerPINAgentState is whether a host has an agent that could apply an end-user-chosen startup PIN. | ||
| type bitLockerPINAgentState int | ||
|
|
||
| const ( | ||
| // bitLockerPINAgentNotEligible means the host is not Windows, or is not enrolled in Windows MDM, so no agent could apply a PIN. | ||
| bitLockerPINAgentNotEligible bitLockerPINAgentState = iota | ||
| // bitLockerPINAgentTooOld means the host is enrolled, but its fleetd predates the BitLocker PIN capability. | ||
| bitLockerPINAgentTooOld | ||
| bitLockerPINAgentCapable | ||
| ) | ||
|
|
||
| // bitLockerPINAgent reports whether this host's agent could apply an end-user-chosen BitLocker startup PIN. | ||
| func (svc *Service) bitLockerPINAgent(ctx context.Context, host *fleet.Host) (bitLockerPINAgentState, error) { | ||
| if host.FleetPlatform() != "windows" { | ||
| return bitLockerPINAgentNotEligible, nil | ||
| } | ||
|
|
||
| state, err := svc.ds.GetMDMWindowsHostConfigState(ctx, host.UUID) | ||
| switch { | ||
| case fleet.IsNotFound(err): | ||
| return bitLockerPINAgentNotEligible, nil | ||
| case err != nil: | ||
| return bitLockerPINAgentNotEligible, ctxerr.Wrap(ctx, err, "get windows mdm config state for bitlocker pin") | ||
| case !state.FleetdBitLockerPINCapable: | ||
| return bitLockerPINAgentTooOld, nil | ||
| } | ||
| return bitLockerPINAgentCapable, nil | ||
| } | ||
|
|
||
| func (svc *Service) SubmitBitLockerPIN(ctx context.Context, host *fleet.Host, pin string) error { | ||
| // The device auth token in the URL is the authorization for this endpoint; there is no Fleet user. | ||
| svc.authz.SkipAuthorization(ctx) | ||
|
|
||
| if err := microsoft_mdm.ValidateBitLockerPIN(pin); err != nil { | ||
| return ctxerr.Wrap(ctx, err, "validate bitlocker pin") | ||
| } | ||
|
|
||
| // Re-check eligibility on submit rather than trusting the page, which may be showing a stale view of a host whose | ||
| // fleet stopped requiring a PIN, or whose PIN another session already set. The capability is checked first because | ||
| // it comes off a cheap row, and an agent that can't apply a PIN makes the BitLocker status irrelevant. | ||
| notNeeded := &fleet.BadRequestError{Message: bitLockerPINNotNeededMessage} | ||
| agent, err := svc.bitLockerPINAgent(ctx, host) | ||
| switch { | ||
| case err != nil: | ||
| return err | ||
| case agent == bitLockerPINAgentNotEligible: | ||
| return notNeeded | ||
| case agent == bitLockerPINAgentTooOld: | ||
| return &fleet.BadRequestError{Message: bitLockerPINAgentTooOldMessage} | ||
| } | ||
| de, err := svc.ds.GetMDMWindowsBitLockerStatus(ctx, host) | ||
| if err != nil { | ||
| return ctxerr.Wrap(ctx, err, "get bitlocker status for pin eligibility") | ||
| } | ||
| if !de.NeedsBitLockerPIN() { | ||
| return notNeeded | ||
| } | ||
|
|
||
| if svc.config.Server.PrivateKey == "" { | ||
| return ctxerr.New(ctx, "internal error: missing server private key") | ||
| } | ||
| encryptedPIN, err := mdm.EncryptAndEncode(pin, svc.config.Server.PrivateKey) | ||
| if err != nil { | ||
| // Deliberately not wrapping the underlying error into a user-facing message: it can echo the input. | ||
| return ctxerr.Wrap(ctx, err, "internal error: could not encrypt BitLocker PIN") | ||
| } | ||
|
|
||
| return ctxerr.Wrap(ctx, svc.ds.QueueBitLockerPINRequest(ctx, host, encryptedPIN), "queue bitlocker pin request") | ||
| } | ||
|
|
||
| // BitLockerPINStateForDevice reports what the My device page needs to decide which modal to show and what to poll for. | ||
| func (svc *Service) BitLockerPINStateForDevice( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this has "request semantics" (calls svc.authz.SkipAuthorization(ctx), returns a request), but from what I can tell its not attached to any handler and it's only used by getDeviceHostEndpoint - maybe this can be refactored to an utility method?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, the word |
||
| ctx context.Context, host *fleet.Host, | ||
| ) (bool, *fleet.HostBitLockerPINRequest, error) { | ||
| // Device-authenticated. | ||
| svc.authz.SkipAuthorization(ctx) | ||
|
|
||
| // Unlike the submit path, this deliberately does not check whether the host still needs a PIN: the page has to be able to | ||
| // show the outcome of a submission even after the PIN is applied and the host no longer needs one. | ||
| agent, err := svc.bitLockerPINAgent(ctx, host) | ||
| if err != nil || agent != bitLockerPINAgentCapable { | ||
| return false, nil, err | ||
| } | ||
|
|
||
| req, err := svc.ds.GetBitLockerPINRequest(ctx, host.ID) | ||
| switch { | ||
| case fleet.IsNotFound(err): | ||
| req = nil | ||
| case err != nil: | ||
| return false, nil, ctxerr.Wrap(ctx, err, "get bitlocker pin request") | ||
| } | ||
| // The cleanups cron retires abandoned submissions, but the page should not have to wait for it: a request past its | ||
| // TTL is already uncollectable, so report it as timed out rather than leaving the modal spinning on "pending". | ||
| if req != nil && req.Expired(time.Now()) { | ||
| req = &fleet.HostBitLockerPINRequest{ | ||
| Status: fleet.BitLockerPINRequestFailed, | ||
| Error: fleet.BitLockerPINRequestTimedOutError, | ||
| CreatedAt: req.CreatedAt, | ||
| } | ||
| } | ||
|
|
||
| return true, req, nil | ||
| } | ||
|
|
||
| func (svc *Service) GetBitLockerPINForHost(ctx context.Context) (string, string, error) { | ||
| // Orbit node key authentication, not a Fleet user. | ||
| svc.authz.SkipAuthorization(ctx) | ||
|
|
||
| host, ok := hostctx.FromContext(ctx) | ||
| if !ok { | ||
| return "", "", ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) | ||
| } | ||
|
|
||
| if svc.config.Server.PrivateKey == "" { | ||
| return "", "", ctxerr.New(ctx, "internal error: missing server private key") | ||
| } | ||
|
|
||
| encryptedPIN, requestUUID, err := svc.ds.TakeBitLockerPINRequest(ctx, host) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the happy path, this sets the PIN request to
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right. I fixed it in the follow up PR (didn't want to continue changing this one): #53277 |
||
| if err != nil { | ||
| // notFound covers never-submitted, already-collected, already-finished and expired alike. The agent treats | ||
| // them identically: there is nothing to apply on this poll. | ||
| return "", "", ctxerr.Wrap(ctx, err, "take bitlocker pin request") | ||
| } | ||
|
|
||
| pin, err := mdm.DecodeAndDecrypt(encryptedPIN, svc.config.Server.PrivateKey) | ||
| if err != nil { | ||
| // The ciphertext is already gone, so nothing can rescue this submission. Retire it as failed. | ||
| if outcomeErr := svc.ds.SetBitLockerPINRequestOutcome(ctx, host, requestUUID, | ||
| fleet.BitLockerPINRequestFailed, bitLockerPINUnreadableError); outcomeErr != nil { | ||
| svc.logger.ErrorContext(ctx, "retiring undecryptable bitlocker pin request", "err", outcomeErr) | ||
| } | ||
| return "", "", ctxerr.Wrap(ctx, err, "internal error: could not decrypt BitLocker PIN") | ||
| } | ||
|
|
||
| return pin, requestUUID, nil | ||
| } | ||
|
|
||
| func (svc *Service) SetBitLockerPINOutcome( | ||
| ctx context.Context, requestUUID string, outcome fleet.BitLockerPINRequestStatus, clientError string, | ||
| ) error { | ||
| // Orbit node key authentication, not a Fleet user. | ||
| svc.authz.SkipAuthorization(ctx) | ||
|
|
||
| host, ok := hostctx.FromContext(ctx) | ||
| if !ok { | ||
| return ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) | ||
| } | ||
|
|
||
| // clientError is untrusted input from fleetd: normalize it before judging whether it says anything. | ||
| clientError = str.TruncateRunes(strings.TrimSpace(clientError), fleet.BitLockerPINClientErrorMaxLength) | ||
|
getvictor marked this conversation as resolved.
|
||
|
|
||
| switch outcome { | ||
| case fleet.BitLockerPINRequestSet: | ||
| // Record the outcome first, and only go on if it actually landed on a submission this host collected. | ||
| if err := svc.ds.SetBitLockerPINRequestOutcome(ctx, host, requestUUID, outcome, ""); err != nil { | ||
| return ctxerr.Wrap(ctx, err, "set bitlocker pin request outcome") | ||
| } | ||
|
|
||
| // From here on the outcome is recorded, so nothing below may fail the request. | ||
| // Set the flags so the end user's banner clears now, and ask for a refetch so osquery confirms it within seconds. A | ||
| // TPM and PIN protector can release the volume master key at boot, so it is also a boot protector. | ||
| if err := svc.ds.SetOrUpdateHostDiskBitLockerProtectors(ctx, host.ID, true, true); err != nil { | ||
| svc.logger.ErrorContext(ctx, "recording bitlocker pin set after outcome", "host_id", host.ID, "err", err) | ||
| ctxerr.Handle(ctx, err) | ||
| } | ||
| if err := svc.ds.UpdateHostRefetchRequested(ctx, host.ID, true); err != nil { | ||
| svc.logger.ErrorContext(ctx, "requesting refetch after bitlocker pin set", "host_id", host.ID, "err", err) | ||
| ctxerr.Handle(ctx, err) | ||
| } | ||
| // The end user chose the PIN, so this is deliberately recorded with no actor rather than as Fleet-initiated. | ||
| if err := svc.NewActivity(ctx, nil, fleet.ActivityTypeCreatedDiskEncryptionPIN{ | ||
| HostID: host.ID, | ||
| HostDisplayName: host.DisplayName(), | ||
| }); err != nil { | ||
| // OK: see above, the outcome is already recorded. | ||
| svc.logger.ErrorContext(ctx, "record created disk encryption pin activity", "err", err) | ||
| ctxerr.Handle(ctx, err) | ||
| } | ||
|
|
||
| case fleet.BitLockerPINRequestFailed: | ||
| if clientError == "" { | ||
| return fleet.NewInvalidArgumentError("client_error", "cannot be empty when outcome is failed") | ||
| } | ||
| if err := svc.ds.SetBitLockerPINRequestOutcome(ctx, host, requestUUID, outcome, clientError); err != nil { | ||
| return ctxerr.Wrap(ctx, err, "set bitlocker pin request outcome") | ||
| } | ||
|
|
||
| default: | ||
| // The outcome is untrusted input from fleetd, so it is truncated rather than echoed whole into the error and the logs. | ||
| return &fleet.BadRequestError{Message: "unknown outcome " + str.TruncateRunes(string(outcome), 32)} | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.