Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions openapi/spec/components/schemas/installKey.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ properties:
created before at-rest encryption. The full key is available via reveal.
example: 1e7b0f4b
expires_at:
description: The absolute date the key expires. Null means it never expires.
description: When the key expires. Null means it never expires.
type: string
format: date
format: date-time
nullable: true
example: 2026-08-20
example: 2026-08-20T00:00:00Z
created_at:
type: string
description: The UTC date when the key was created.
Expand Down
14 changes: 7 additions & 7 deletions openapi/spec/components/schemas/installKeyCreate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ properties:
minimum: 0
maximum: 86400
example: 3600
expires_at:
type: string
format: date-time
nullable: true
expires_in:
type: integer
minimum: 1
maximum: 36500
description: |
Absolute date the key expires (must be in the future). Null or omitted
means the key never expires.
example: 2026-08-20T00:00:00Z
How many days the key should keep working (max 36500 ≈ 100 years).
Omit it for a key that never expires.
example: 30
usage_limit:
type: integer
description: |
Expand Down
13 changes: 7 additions & 6 deletions openapi/spec/components/schemas/installKeyUpdate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@ properties:
Pause or resume the key. Reversible: set to true to stop the key enrolling
devices, false to re-enable it.
example: true
expires_at:
type: string
format: date-time
expires_in:
type: integer
minimum: 1
maximum: 36500
nullable: true
description: |
Set a new absolute expiration date (must be in the future). Omit to leave
the current expiry unchanged; null makes the key never expire.
example: 2026-08-20T00:00:00Z
Set a new expiration as days from now (1–36500). Omit to leave the
current expiry unchanged; null makes the key never expire.
example: 30
usage_limit:
type: integer
minimum: 0
Expand Down
4 changes: 2 additions & 2 deletions openapi/spec/components/schemas/installKeyWithKey.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ properties:
example: false
expires_at:
type: string
format: date
format: date-time
nullable: true
example: 2026-08-20
example: 2026-08-20T00:00:00Z
created_at:
type: string
format: date
Expand Down
24 changes: 11 additions & 13 deletions pkg/api/requests/install-key.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@ package requests

import (
"encoding/json"
"time"

"github.com/shellhub-io/shellhub/pkg/api/query"
)

// OptionalTime carries RFC 7396 (JSON Merge Patch) semantics for a nullable field in a partial
// update: an omitted key leaves the value unchanged (Present is false), an explicit null clears it
// (Present is true, Value is nil), and a timestamp sets it.
type OptionalTime struct {
// OptionalInt carries RFC 7396 (JSON Merge Patch) semantics for a nullable integer field in a
// partial update: an omitted key leaves the value unchanged (Present is false), an explicit null
// clears it (Present is true, Value is nil), and a number sets it.
type OptionalInt struct {
Present bool
Value *time.Time
Value *int
}

func (o *OptionalTime) UnmarshalJSON(data []byte) error {
func (o *OptionalInt) UnmarshalJSON(data []byte) error {
o.Present = true
if string(data) == "null" {
o.Value = nil
Expand All @@ -41,9 +40,8 @@ type CreateInstallKey struct {
// max 24h) is the deferred-decision token's validity. 0/omitted uses the server default.
WebhookTimeout int `json:"webhook_timeout" validate:"omitempty,min=0,max=15"`
WebhookCallbackTTL int `json:"webhook_callback_ttl" validate:"omitempty,min=0,max=86400"`
// ExpiresAt is the absolute date the key expires. A null (or omitted) value means the key never
// expires. When set, it must be in the future.
ExpiresAt *time.Time `json:"expires_at"`
// ExpiresIn is how many days from now the key should expire. Nil/omitted means no expiration.
ExpiresIn *int `json:"expires_in" validate:"omitempty,min=1,max=36500"`
// UsageLimit caps how many devices may enroll: 1 is single-use (one-off), a higher value is that
// many devices, 0 (or omitted) is unlimited. Whether the key is reusable is derived from this.
UsageLimit int `json:"usage_limit" validate:"omitempty,min=0"`
Expand Down Expand Up @@ -83,9 +81,9 @@ type UpdateInstallKey struct {
// Disabled toggles the reversible pause. Both true and false are honored, so a disabled key can
// be re-enabled (unlike Revoked).
Disabled *bool `json:"disabled"`
// ExpiresAt sets a new absolute expiration date (must be in the future). Omitted leaves the
// current expiry unchanged; null makes the key never expire (RFC 7396 semantics).
ExpiresAt OptionalTime `json:"expires_at"`
// ExpiresIn sets a new expiration as days from now. RFC 7396: omitted leaves the current expiry
// unchanged, null clears it (never expires), a positive integer sets it.
ExpiresIn OptionalInt `json:"expires_in"`
// UsageLimit sets a new enrollment cap (0 unlimited, 1 single-use, N devices). Nil leaves it
// untouched. Reusability is re-derived from it.
UsageLimit *int `json:"usage_limit" validate:"omitempty,min=0"`
Expand Down
29 changes: 13 additions & 16 deletions server/api/services/install-key.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ const (
installKeyMaxEphemeralTimeout = 10
)

// validateInstallKeyExpiry rejects an expiration that is not in the future. A nil expiry (never
// expires) is always valid.
func validateInstallKeyExpiry(expiresAt *time.Time) error {
if expiresAt != nil && !expiresAt.After(clock.Now()) {
return NewErrBadRequest(errors.New("expires_at must be a future date"))
// installKeyExpiry converts a relative day count into an absolute timestamp anchored to clock.Now().
func installKeyExpiry(days *int) *time.Time {
if days == nil {
return nil
}

return nil
at := clock.Now().AddDate(0, 0, *days)

return &at
}

// normalizeMACs lowercases and trims each MAC and drops blanks, so allowlist matching is
Expand Down Expand Up @@ -190,10 +191,6 @@ func (s *service) CreateInstallKey(ctx context.Context, req *requests.CreateInst
return nil, NewErrNamespaceNotFound(req.TenantID, err)
}

if err := validateInstallKeyExpiry(req.ExpiresAt); err != nil {
return nil, err
}

// Default to automatic (the classic auto-accept behavior) when no mode is given.
mode := models.InstallKeyMode(req.Mode)
if mode == "" {
Expand Down Expand Up @@ -254,7 +251,7 @@ func (s *service) CreateInstallKey(ctx context.Context, req *requests.CreateInst
Ephemeral: req.Ephemeral,
EphemeralTimeout: ephemeralTimeout,
Tags: req.Tags,
ExpiresAt: req.ExpiresAt,
ExpiresAt: installKeyExpiry(req.ExpiresIn),
CreatedBy: req.UserID,
KeyEncrypted: encryptedKey,
KeyHint: installKeyHint(key),
Expand Down Expand Up @@ -328,7 +325,7 @@ func (s *service) UpdateInstallKey(ctx context.Context, req *requests.UpdateInst
// entirely — devices without a key are rejected). Its other fields (name/lifecycle/limit/tags) are
// fixed. Revoke never applies: the legacy key is permanent.
if installKey.IsSystem() {
if req.Name != "" || req.Revoked != nil || req.UsageLimit != nil || req.ExpiresAt.Present || req.Tags != nil || req.Ephemeral != nil || req.EphemeralTimeout != nil {
if req.Name != "" || req.Revoked != nil || req.UsageLimit != nil || req.ExpiresIn.Present || req.Tags != nil || req.Ephemeral != nil || req.EphemeralTimeout != nil {
return NewErrInstallKeyForbidden()
}
} else if installKey.Revoked {
Expand Down Expand Up @@ -431,14 +428,14 @@ func (s *service) UpdateInstallKey(ctx context.Context, req *requests.UpdateInst

// RFC 7396 semantics: only touch the expiry when the field was sent, so a revoke or disable that
// omits it never wipes the key's lifetime. Present with a nil value clears it (never expires).
if req.ExpiresAt.Present {
if req.ExpiresAt.Value != nil && !req.ExpiresAt.Value.After(clock.Now()) {
if req.ExpiresIn.Present {
if req.ExpiresIn.Value != nil && (*req.ExpiresIn.Value < 1 || *req.ExpiresIn.Value > 36500) {
return NewErrInstallKeyInvalidField(map[string]string{
"expires_at": "must be a future date",
"expires_in": "must be between 1 and 36500",
})
}

installKey.ExpiresAt = req.ExpiresAt.Value
installKey.ExpiresAt = installKeyExpiry(req.ExpiresIn.Value)
}

if err := s.store.InstallKeyUpdate(ctx, installKey); err != nil { //nolint:revive
Expand Down
102 changes: 96 additions & 6 deletions server/api/services/install-key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestCreateInstallKey(t *testing.T) {
hashedKey := hex.EncodeToString(keySum[:])

future := now.AddDate(0, 0, 30)
past := now.Add(-time.Hour)
days30 := 30

// InstallKeyCreate receives a struct whose KeyEncrypted has a random nonce, so match on the
// deterministic fields and assert the ciphertext and hint were populated.
Expand Down Expand Up @@ -93,13 +93,24 @@ func TestCreateInstallKey(t *testing.T) {
expectedErr: NewErrNamespaceNotFound(tenant, errors.New("error")),
},
{
description: "fails when the expiration is in the past",
req: &requests.CreateInstallKey{TenantID: tenant, Name: "ci", ExpiresAt: &past},
description: "creates a key that never expires when expires_in is omitted",
req: &requests.CreateInstallKey{UserID: "000000000000000000000000", TenantID: tenant, Name: "ci"},
requiredMocks: func(ctx context.Context) {
storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant).
Return(namespace, nil).Once()
uuidMock := uuidmock.NewMockUUID(t)
uuid.DefaultBackend = uuidMock
uuidMock.On("Generate").Return(generated).Once()
storeMock.On("InstallKeyConflicts", ctx, scope.MustBounded(tenant), &models.InstallKeyConflicts{ID: hashedKey, Name: "ci"}).
Return([]string{}, false, nil).Once()
storeMock.On("InstallKeyCreate", ctx, matchCreate(&models.InstallKey{
ID: hashedKey, Name: "ci", TenantID: tenant, Reusable: true,
CreatedBy: "000000000000000000000000",
})).Return(hashedKey, nil).Once()
storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyIDResolver, hashedKey).
Return(&models.InstallKey{ID: hashedKey, Name: "ci", TenantID: tenant, Reusable: true}, nil).Once()
},
expectedErr: NewErrBadRequest(errors.New("expires_at must be a future date")),
expectedKey: plain,
},
{
description: "fails when webhook mode has no http(s) url",
Expand Down Expand Up @@ -209,8 +220,8 @@ func TestCreateInstallKey(t *testing.T) {
expectedKey: plain,
},
{
description: "stores the provided expiration date",
req: &requests.CreateInstallKey{UserID: "000000000000000000000000", TenantID: tenant, Name: "ci", ExpiresAt: &future},
description: "converts expires_in days to an absolute expiry",
req: &requests.CreateInstallKey{UserID: "000000000000000000000000", TenantID: tenant, Name: "ci", ExpiresIn: &days30},
requiredMocks: func(ctx context.Context) {
storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant).
Return(namespace, nil).Once()
Expand Down Expand Up @@ -287,6 +298,14 @@ func TestUpdateInstallKey(t *testing.T) {
storeMock := storemock.NewMockStore(t)
queryOptionsMock := storemock.NewMockQueryOptions(t)
storeMock.On("Options").Return(queryOptionsMock).Maybe()

now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
clockMock := clockmock.NewMockClock(t)
prevClock := clock.DefaultBackend
clock.DefaultBackend = clockMock
defer func() { clock.DefaultBackend = prevClock }()
clockMock.On("Now").Return(now).Maybe()

const tenant = "00000000-0000-4000-0000-000000000000"
namespace := &models.Namespace{Name: "namespace", TenantID: tenant}
truePtr := true
Expand All @@ -295,6 +314,9 @@ func TestUpdateInstallKey(t *testing.T) {
limitUnlimited := 0
ephemeralTimeout5 := 5
modeAutomatic := "automatic"
days60 := 60
days0 := 0
days36501 := 36501

cases := []struct {
description string
Expand Down Expand Up @@ -467,6 +489,74 @@ func TestUpdateInstallKey(t *testing.T) {
},
expectedErr: nil,
},
{
description: "sets a new expiry from expires_in days",
req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", ExpiresIn: requests.OptionalInt{Present: true, Value: &days60}},
requiredMocks: func(ctx context.Context) {
storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant).
Return(namespace, nil).Once()
storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci").
Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant}, nil).Once()
expiry := now.AddDate(0, 0, 60)
storeMock.On("InstallKeyUpdate", ctx, &models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: &expiry}).
Return(nil).Once()
},
expectedErr: nil,
},
{
description: "clears the expiry when expires_in is null",
req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", ExpiresIn: requests.OptionalInt{Present: true, Value: nil}},
requiredMocks: func(ctx context.Context) {
existing := now.AddDate(0, 0, 30)
storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant).
Return(namespace, nil).Once()
storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci").
Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: &existing}, nil).Once()
storeMock.On("InstallKeyUpdate", ctx, &models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: nil}).
Return(nil).Once()
},
expectedErr: nil,
},
{
description: "leaves the expiry unchanged when expires_in is omitted",
req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", Revoked: &truePtr},
requiredMocks: func(ctx context.Context) {
existing := now.AddDate(0, 0, 30)
storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant).
Return(namespace, nil).Once()
storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci").
Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: &existing, Reusable: true}, nil).Once()
storeMock.On("InstallKeyUpdate", ctx, &models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: &existing, Reusable: true, Revoked: true}).
Return(nil).Once()
},
expectedErr: nil,
},
{
description: "rejects expires_in below 1",
req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", ExpiresIn: requests.OptionalInt{Present: true, Value: &days0}},
requiredMocks: func(ctx context.Context) {
storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant).
Return(namespace, nil).Once()
storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci").
Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant}, nil).Once()
},
expectedErr: NewErrInstallKeyInvalidField(map[string]string{
"expires_in": "must be between 1 and 36500",
}),
},
{
description: "rejects expires_in above 36500",
req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", ExpiresIn: requests.OptionalInt{Present: true, Value: &days36501}},
requiredMocks: func(ctx context.Context) {
storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant).
Return(namespace, nil).Once()
storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci").
Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant}, nil).Once()
},
expectedErr: NewErrInstallKeyInvalidField(map[string]string{
"expires_in": "must be between 1 and 36500",
}),
},
{
description: "rejects changing ephemeral on the legacy key",
req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "legacy", Ephemeral: &truePtr},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import ExpirationField from "./ExpirationField";
import ModeField, { type InstallKeyMode } from "./ModeField";
import UsageLimitField from "./UsageLimitField";
import {
defaultExpiry,
keyExpiryPayload,
parseAllowedMacs,
validateModeConfig,
validateName,
Expand Down Expand Up @@ -48,7 +48,7 @@ function CreateInstallKeyDrawer({
const [ephemeral, setEphemeral] = useState(false);
const [ephemeralTimeout, setEphemeralTimeout] = useState(10);
const [tags, setTags] = useState<string[]>([]);
const [expiresAt, setExpiresAt] = useState<string | null>(defaultExpiry());
const [expiresIn, setExpiresIn] = useState("30");
const [submitting, setSubmitting] = useState(false);
const [nameError, setNameError] = useState("");
const [error, setError] = useState("");
Expand All @@ -74,7 +74,7 @@ function CreateInstallKeyDrawer({
setEphemeral(false);
setEphemeralTimeout(10);
setTags([]);
setExpiresAt(defaultExpiry());
setExpiresIn("30");
setNameError("");
setError("");
setGeneratedKey("");
Expand Down Expand Up @@ -117,7 +117,7 @@ function CreateInstallKeyDrawer({
}
: {}),
...(mode === "allowlist" ? { allowed_macs: macList } : {}),
expires_at: expiresAt,
...keyExpiryPayload(expiresIn),
// Reusability is derived server-side from this: 1 is single-use,
// N (>=2) enrolls N devices, 0 is unlimited (reusable forever).
usage_limit: usageLimit,
Expand Down Expand Up @@ -249,7 +249,10 @@ function CreateInstallKeyDrawer({
webhookCallbackTtl={webhookCallbackTtl}
onWebhookCallbackTtlChange={setWebhookCallbackTtl}
/>
<ExpirationField value={expiresAt} onChange={setExpiresAt} />
<ExpirationField
expiresIn={expiresIn}
onExpiresInChange={setExpiresIn}
/>
<UsageLimitField value={usageLimit} onChange={setUsageLimit} />
<EphemeralField
id="create-install-key-ephemeral"
Expand Down
Loading
Loading