2021-08-19 21:12:21 +02:00
|
|
|
package server
|
|
|
|
|
|
|
|
import (
|
2021-08-20 22:33:43 +02:00
|
|
|
"hash/fnv"
|
|
|
|
"strconv"
|
2021-08-19 21:12:21 +02:00
|
|
|
"strings"
|
|
|
|
"time"
|
2022-11-05 10:24:50 +01:00
|
|
|
"unicode/utf8"
|
2023-04-03 15:09:35 +02:00
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
|
|
|
|
"github.com/netbirdio/netbird/management/server/activity"
|
|
|
|
"github.com/netbirdio/netbird/management/server/status"
|
2021-08-19 21:12:21 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
// SetupKeyReusable is a multi-use key (can be used for multiple machines)
|
|
|
|
SetupKeyReusable SetupKeyType = "reusable"
|
|
|
|
// SetupKeyOneOff is a single use key (can be used only once)
|
|
|
|
SetupKeyOneOff SetupKeyType = "one-off"
|
|
|
|
|
|
|
|
// DefaultSetupKeyDuration = 1 month
|
|
|
|
DefaultSetupKeyDuration = 24 * 30 * time.Hour
|
|
|
|
// DefaultSetupKeyName is a default name of the default setup key
|
|
|
|
DefaultSetupKeyName = "Default key"
|
2022-12-05 13:09:59 +01:00
|
|
|
// SetupKeyUnlimitedUsage indicates an unlimited usage of a setup key
|
|
|
|
SetupKeyUnlimitedUsage = 0
|
|
|
|
)
|
2022-09-11 23:16:40 +02:00
|
|
|
|
2022-12-05 13:09:59 +01:00
|
|
|
const (
|
2022-09-11 23:16:40 +02:00
|
|
|
// UpdateSetupKeyName indicates a setup key name update operation
|
|
|
|
UpdateSetupKeyName SetupKeyUpdateOperationType = iota
|
|
|
|
// UpdateSetupKeyRevoked indicates a setup key revoked filed update operation
|
|
|
|
UpdateSetupKeyRevoked
|
|
|
|
// UpdateSetupKeyAutoGroups indicates a setup key auto-assign groups update operation
|
|
|
|
UpdateSetupKeyAutoGroups
|
|
|
|
// UpdateSetupKeyExpiresAt indicates a setup key expiration time update operation
|
|
|
|
UpdateSetupKeyExpiresAt
|
2021-08-19 21:12:21 +02:00
|
|
|
)
|
|
|
|
|
2022-09-11 23:16:40 +02:00
|
|
|
// SetupKeyUpdateOperationType operation type
|
|
|
|
type SetupKeyUpdateOperationType int
|
|
|
|
|
|
|
|
func (t SetupKeyUpdateOperationType) String() string {
|
|
|
|
switch t {
|
|
|
|
case UpdateSetupKeyName:
|
|
|
|
return "UpdateSetupKeyName"
|
|
|
|
case UpdateSetupKeyRevoked:
|
|
|
|
return "UpdateSetupKeyRevoked"
|
|
|
|
case UpdateSetupKeyAutoGroups:
|
|
|
|
return "UpdateSetupKeyAutoGroups"
|
|
|
|
case UpdateSetupKeyExpiresAt:
|
|
|
|
return "UpdateSetupKeyExpiresAt"
|
|
|
|
default:
|
|
|
|
return "InvalidOperation"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetupKeyUpdateOperation operation object with type and values to be applied
|
|
|
|
type SetupKeyUpdateOperation struct {
|
|
|
|
Type SetupKeyUpdateOperationType
|
|
|
|
Values []string
|
|
|
|
}
|
|
|
|
|
2021-08-19 21:12:21 +02:00
|
|
|
// SetupKeyType is the type of setup key
|
|
|
|
type SetupKeyType string
|
|
|
|
|
|
|
|
// SetupKey represents a pre-authorized key used to register machines (peers)
|
|
|
|
type SetupKey struct {
|
2021-08-20 22:33:43 +02:00
|
|
|
Id string
|
2021-08-19 21:12:21 +02:00
|
|
|
Key string
|
|
|
|
Name string
|
|
|
|
Type SetupKeyType
|
|
|
|
CreatedAt time.Time
|
|
|
|
ExpiresAt time.Time
|
2022-09-11 23:16:40 +02:00
|
|
|
UpdatedAt time.Time
|
2021-08-19 21:12:21 +02:00
|
|
|
// Revoked indicates whether the key was revoked or not (we don't remove them for tracking purposes)
|
|
|
|
Revoked bool
|
|
|
|
// UsedTimes indicates how many times the key was used
|
|
|
|
UsedTimes int
|
2021-08-22 11:29:25 +02:00
|
|
|
// LastUsed last time the key was used for peer registration
|
|
|
|
LastUsed time.Time
|
2022-09-11 23:16:40 +02:00
|
|
|
// AutoGroups is a list of Group IDs that are auto assigned to a Peer when it uses this key to register
|
|
|
|
AutoGroups []string
|
2022-12-05 13:09:59 +01:00
|
|
|
// UsageLimit indicates the number of times this key can be used to enroll a machine.
|
|
|
|
// The value of 0 indicates the unlimited usage.
|
|
|
|
UsageLimit int
|
2021-08-19 21:12:21 +02:00
|
|
|
}
|
|
|
|
|
2022-09-11 23:16:40 +02:00
|
|
|
// Copy copies SetupKey to a new object
|
2021-08-20 22:33:43 +02:00
|
|
|
func (key *SetupKey) Copy() *SetupKey {
|
2022-09-13 17:19:03 +02:00
|
|
|
autoGroups := make([]string, 0)
|
|
|
|
autoGroups = append(autoGroups, key.AutoGroups...)
|
2022-09-11 23:16:40 +02:00
|
|
|
if key.UpdatedAt.IsZero() {
|
|
|
|
key.UpdatedAt = key.CreatedAt
|
|
|
|
}
|
2021-08-20 22:33:43 +02:00
|
|
|
return &SetupKey{
|
2022-09-11 23:16:40 +02:00
|
|
|
Id: key.Id,
|
|
|
|
Key: key.Key,
|
|
|
|
Name: key.Name,
|
|
|
|
Type: key.Type,
|
|
|
|
CreatedAt: key.CreatedAt,
|
|
|
|
ExpiresAt: key.ExpiresAt,
|
|
|
|
UpdatedAt: key.UpdatedAt,
|
|
|
|
Revoked: key.Revoked,
|
|
|
|
UsedTimes: key.UsedTimes,
|
|
|
|
LastUsed: key.LastUsed,
|
2022-09-13 17:19:03 +02:00
|
|
|
AutoGroups: autoGroups,
|
2022-12-05 13:09:59 +01:00
|
|
|
UsageLimit: key.UsageLimit,
|
2021-08-20 22:33:43 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-02 15:11:32 +01:00
|
|
|
// EventMeta returns activity event meta related to the setup key
|
|
|
|
func (key *SetupKey) EventMeta() map[string]any {
|
|
|
|
return map[string]any{"name": key.Name, "type": key.Type, "key": key.HiddenCopy(1).Key}
|
|
|
|
}
|
|
|
|
|
2022-11-05 10:24:50 +01:00
|
|
|
// HiddenCopy returns a copy of the key with a Key value hidden with "*" and a 5 character prefix.
|
|
|
|
// E.g., "831F6*******************************"
|
2023-01-02 15:11:32 +01:00
|
|
|
func (key *SetupKey) HiddenCopy(length int) *SetupKey {
|
2022-11-05 10:24:50 +01:00
|
|
|
k := key.Copy()
|
|
|
|
prefix := k.Key[0:5]
|
2023-01-02 15:11:32 +01:00
|
|
|
if length > utf8.RuneCountInString(key.Key) {
|
|
|
|
length = utf8.RuneCountInString(key.Key) - len(prefix)
|
|
|
|
}
|
|
|
|
k.Key = prefix + strings.Repeat("*", length)
|
2022-11-05 10:24:50 +01:00
|
|
|
return k
|
|
|
|
}
|
|
|
|
|
2022-09-11 23:16:40 +02:00
|
|
|
// IncrementUsage makes a copy of a key, increments the UsedTimes by 1 and sets LastUsed to now
|
2021-08-22 11:29:25 +02:00
|
|
|
func (key *SetupKey) IncrementUsage() *SetupKey {
|
|
|
|
c := key.Copy()
|
|
|
|
c.UsedTimes = c.UsedTimes + 1
|
2023-04-03 15:09:35 +02:00
|
|
|
c.LastUsed = time.Now().UTC()
|
2021-08-22 11:29:25 +02:00
|
|
|
return c
|
|
|
|
}
|
|
|
|
|
2021-08-19 21:12:21 +02:00
|
|
|
// IsValid is true if the key was not revoked, is not expired and used not more than it was supposed to
|
|
|
|
func (key *SetupKey) IsValid() bool {
|
2021-08-25 14:16:17 +02:00
|
|
|
return !key.IsRevoked() && !key.IsExpired() && !key.IsOverUsed()
|
|
|
|
}
|
|
|
|
|
|
|
|
// IsRevoked if key was revoked
|
|
|
|
func (key *SetupKey) IsRevoked() bool {
|
|
|
|
return key.Revoked
|
|
|
|
}
|
|
|
|
|
|
|
|
// IsExpired if key was expired
|
|
|
|
func (key *SetupKey) IsExpired() bool {
|
2023-04-03 15:09:35 +02:00
|
|
|
return time.Now().UTC().After(key.ExpiresAt)
|
2021-08-25 14:16:17 +02:00
|
|
|
}
|
|
|
|
|
2022-12-05 13:09:59 +01:00
|
|
|
// IsOverUsed if the key was used too many times. SetupKey.UsageLimit == 0 indicates the unlimited usage.
|
2021-08-25 14:16:17 +02:00
|
|
|
func (key *SetupKey) IsOverUsed() bool {
|
2022-12-05 13:09:59 +01:00
|
|
|
limit := key.UsageLimit
|
|
|
|
if key.Type == SetupKeyOneOff {
|
|
|
|
limit = 1
|
|
|
|
}
|
|
|
|
return limit > 0 && key.UsedTimes >= limit
|
2021-08-19 21:12:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// GenerateSetupKey generates a new setup key
|
2022-12-05 13:09:59 +01:00
|
|
|
func GenerateSetupKey(name string, t SetupKeyType, validFor time.Duration, autoGroups []string,
|
|
|
|
usageLimit int) *SetupKey {
|
2021-08-20 22:33:43 +02:00
|
|
|
key := strings.ToUpper(uuid.New().String())
|
2022-12-05 13:09:59 +01:00
|
|
|
limit := usageLimit
|
|
|
|
if t == SetupKeyOneOff {
|
|
|
|
limit = 1
|
|
|
|
}
|
2021-08-19 21:12:21 +02:00
|
|
|
return &SetupKey{
|
2022-09-11 23:16:40 +02:00
|
|
|
Id: strconv.Itoa(int(Hash(key))),
|
|
|
|
Key: key,
|
|
|
|
Name: name,
|
|
|
|
Type: t,
|
2023-04-03 15:09:35 +02:00
|
|
|
CreatedAt: time.Now().UTC(),
|
|
|
|
ExpiresAt: time.Now().UTC().Add(validFor),
|
|
|
|
UpdatedAt: time.Now().UTC(),
|
2022-09-11 23:16:40 +02:00
|
|
|
Revoked: false,
|
|
|
|
UsedTimes: 0,
|
|
|
|
AutoGroups: autoGroups,
|
2022-12-05 13:09:59 +01:00
|
|
|
UsageLimit: limit,
|
2021-08-19 21:12:21 +02:00
|
|
|
}
|
|
|
|
}
|
2021-08-20 15:01:57 +02:00
|
|
|
|
2022-12-05 13:09:59 +01:00
|
|
|
// GenerateDefaultSetupKey generates a default reusable setup key with an unlimited usage and 30 days expiration
|
2021-08-20 15:01:57 +02:00
|
|
|
func GenerateDefaultSetupKey() *SetupKey {
|
2022-12-05 13:09:59 +01:00
|
|
|
return GenerateSetupKey(DefaultSetupKeyName, SetupKeyReusable, DefaultSetupKeyDuration, []string{},
|
|
|
|
SetupKeyUnlimitedUsage)
|
2021-08-20 15:01:57 +02:00
|
|
|
}
|
2021-08-20 22:33:43 +02:00
|
|
|
|
|
|
|
func Hash(s string) uint32 {
|
|
|
|
h := fnv.New32a()
|
|
|
|
_, err := h.Write([]byte(s))
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
return h.Sum32()
|
|
|
|
}
|
2022-09-11 23:16:40 +02:00
|
|
|
|
|
|
|
// CreateSetupKey generates a new setup key with a given name, type, list of groups IDs to auto-assign to peers registered with this key,
|
|
|
|
// and adds it to the specified account. A list of autoGroups IDs can be empty.
|
|
|
|
func (am *DefaultAccountManager) CreateSetupKey(accountID string, keyName string, keyType SetupKeyType,
|
2023-01-02 15:11:32 +01:00
|
|
|
expiresIn time.Duration, autoGroups []string, usageLimit int, userID string) (*SetupKey, error) {
|
2022-11-07 17:52:23 +01:00
|
|
|
unlock := am.Store.AcquireAccountLock(accountID)
|
|
|
|
defer unlock()
|
2022-09-11 23:16:40 +02:00
|
|
|
|
|
|
|
keyDuration := DefaultSetupKeyDuration
|
|
|
|
if expiresIn != 0 {
|
|
|
|
keyDuration = expiresIn
|
|
|
|
}
|
|
|
|
|
|
|
|
account, err := am.Store.GetAccount(accountID)
|
|
|
|
if err != nil {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, err
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
for _, group := range autoGroups {
|
|
|
|
if _, ok := account.Groups[group]; !ok {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, status.Errorf(status.NotFound, "group %s doesn't exist", group)
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-05 13:09:59 +01:00
|
|
|
setupKey := GenerateSetupKey(keyName, keyType, keyDuration, autoGroups, usageLimit)
|
2022-09-11 23:16:40 +02:00
|
|
|
account.SetupKeys[setupKey.Key] = setupKey
|
|
|
|
err = am.Store.SaveAccount(account)
|
|
|
|
if err != nil {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, status.Errorf(status.Internal, "failed adding account key")
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
2023-01-24 10:17:24 +01:00
|
|
|
am.storeEvent(userID, setupKey.Id, accountID, activity.SetupKeyCreated, setupKey.EventMeta())
|
2023-01-02 15:11:32 +01:00
|
|
|
|
|
|
|
for _, g := range setupKey.AutoGroups {
|
|
|
|
group := account.GetGroup(g)
|
|
|
|
if group != nil {
|
2023-01-24 10:17:24 +01:00
|
|
|
am.storeEvent(userID, setupKey.Id, accountID, activity.GroupAddedToSetupKey,
|
|
|
|
map[string]any{"group": group.Name, "group_id": group.ID, "setupkey": setupKey.Name})
|
2023-01-02 15:11:32 +01:00
|
|
|
} else {
|
|
|
|
log.Errorf("group %s not found while saving setup key activity event of account %s", g, account.Id)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-11 23:16:40 +02:00
|
|
|
return setupKey, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// SaveSetupKey saves the provided SetupKey to the database overriding the existing one.
|
|
|
|
// Due to the unique nature of a SetupKey certain properties must not be overwritten
|
|
|
|
// (e.g. the key itself, creation date, ID, etc).
|
|
|
|
// These properties are overwritten: Name, AutoGroups, Revoked. The rest is copied from the existing key.
|
2023-01-02 15:11:32 +01:00
|
|
|
func (am *DefaultAccountManager) SaveSetupKey(accountID string, keyToSave *SetupKey, userID string) (*SetupKey, error) {
|
2022-11-07 17:52:23 +01:00
|
|
|
unlock := am.Store.AcquireAccountLock(accountID)
|
|
|
|
defer unlock()
|
2022-09-11 23:16:40 +02:00
|
|
|
|
|
|
|
if keyToSave == nil {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, status.Errorf(status.InvalidArgument, "provided setup key to update is nil")
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
account, err := am.Store.GetAccount(accountID)
|
|
|
|
if err != nil {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, err
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
var oldKey *SetupKey
|
|
|
|
for _, key := range account.SetupKeys {
|
|
|
|
if key.Id == keyToSave.Id {
|
|
|
|
oldKey = key.Copy()
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if oldKey == nil {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, status.Errorf(status.NotFound, "setup key not found")
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// only auto groups, revoked status, and name can be updated for now
|
|
|
|
newKey := oldKey.Copy()
|
|
|
|
newKey.Name = keyToSave.Name
|
|
|
|
newKey.AutoGroups = keyToSave.AutoGroups
|
|
|
|
newKey.Revoked = keyToSave.Revoked
|
2023-04-03 15:09:35 +02:00
|
|
|
newKey.UpdatedAt = time.Now().UTC()
|
2022-09-11 23:16:40 +02:00
|
|
|
|
|
|
|
account.SetupKeys[newKey.Key] = newKey
|
|
|
|
|
|
|
|
if err = am.Store.SaveAccount(account); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-01-02 15:11:32 +01:00
|
|
|
if !oldKey.Revoked && newKey.Revoked {
|
2023-01-24 10:17:24 +01:00
|
|
|
am.storeEvent(userID, newKey.Id, accountID, activity.SetupKeyRevoked, newKey.EventMeta())
|
2023-01-02 15:11:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
defer func() {
|
|
|
|
addedGroups := difference(newKey.AutoGroups, oldKey.AutoGroups)
|
|
|
|
removedGroups := difference(oldKey.AutoGroups, newKey.AutoGroups)
|
|
|
|
for _, g := range removedGroups {
|
|
|
|
group := account.GetGroup(g)
|
|
|
|
if group != nil {
|
2023-01-24 10:17:24 +01:00
|
|
|
am.storeEvent(userID, oldKey.Id, accountID, activity.GroupRemovedFromSetupKey,
|
|
|
|
map[string]any{"group": group.Name, "group_id": group.ID, "setupkey": newKey.Name})
|
2023-01-02 15:11:32 +01:00
|
|
|
} else {
|
|
|
|
log.Errorf("group %s not found while saving setup key activity event of account %s", g, account.Id)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, g := range addedGroups {
|
|
|
|
group := account.GetGroup(g)
|
|
|
|
if group != nil {
|
2023-01-24 10:17:24 +01:00
|
|
|
am.storeEvent(userID, oldKey.Id, accountID, activity.GroupAddedToSetupKey,
|
|
|
|
map[string]any{"group": group.Name, "group_id": group.ID, "setupkey": newKey.Name})
|
2023-01-02 15:11:32 +01:00
|
|
|
} else {
|
|
|
|
log.Errorf("group %s not found while saving setup key activity event of account %s", g, account.Id)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
2022-09-11 23:16:40 +02:00
|
|
|
return newKey, am.updateAccountPeers(account)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ListSetupKeys returns a list of all setup keys of the account
|
2022-11-05 10:24:50 +01:00
|
|
|
func (am *DefaultAccountManager) ListSetupKeys(accountID, userID string) ([]*SetupKey, error) {
|
2022-11-07 17:52:23 +01:00
|
|
|
unlock := am.Store.AcquireAccountLock(accountID)
|
|
|
|
defer unlock()
|
2022-09-11 23:16:40 +02:00
|
|
|
account, err := am.Store.GetAccount(accountID)
|
|
|
|
if err != nil {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, err
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
2022-11-05 10:24:50 +01:00
|
|
|
user, err := account.FindUser(userID)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-09-11 23:16:40 +02:00
|
|
|
keys := make([]*SetupKey, 0, len(account.SetupKeys))
|
|
|
|
for _, key := range account.SetupKeys {
|
2022-11-05 10:24:50 +01:00
|
|
|
var k *SetupKey
|
|
|
|
if !user.IsAdmin() {
|
2023-01-02 15:11:32 +01:00
|
|
|
k = key.HiddenCopy(999)
|
2022-11-05 10:24:50 +01:00
|
|
|
} else {
|
|
|
|
k = key.Copy()
|
|
|
|
}
|
|
|
|
keys = append(keys, k)
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return keys, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetSetupKey looks up a SetupKey by KeyID, returns NotFound error if not found.
|
2022-11-05 10:24:50 +01:00
|
|
|
func (am *DefaultAccountManager) GetSetupKey(accountID, userID, keyID string) (*SetupKey, error) {
|
2022-11-07 17:52:23 +01:00
|
|
|
unlock := am.Store.AcquireAccountLock(accountID)
|
|
|
|
defer unlock()
|
2022-09-11 23:16:40 +02:00
|
|
|
|
|
|
|
account, err := am.Store.GetAccount(accountID)
|
|
|
|
if err != nil {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, err
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
2022-11-05 10:24:50 +01:00
|
|
|
user, err := account.FindUser(userID)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-09-11 23:16:40 +02:00
|
|
|
var foundKey *SetupKey
|
|
|
|
for _, key := range account.SetupKeys {
|
|
|
|
if key.Id == keyID {
|
|
|
|
foundKey = key.Copy()
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if foundKey == nil {
|
2022-11-11 20:36:45 +01:00
|
|
|
return nil, status.Errorf(status.NotFound, "setup key not found")
|
2022-09-11 23:16:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// the UpdatedAt field was introduced later, so there might be that some keys have a Zero value (e.g, null in the store file)
|
|
|
|
if foundKey.UpdatedAt.IsZero() {
|
|
|
|
foundKey.UpdatedAt = foundKey.CreatedAt
|
|
|
|
}
|
|
|
|
|
2022-11-05 10:24:50 +01:00
|
|
|
if !user.IsAdmin() {
|
2023-01-02 15:11:32 +01:00
|
|
|
foundKey = foundKey.HiddenCopy(999)
|
2022-11-05 10:24:50 +01:00
|
|
|
}
|
|
|
|
|
2022-09-11 23:16:40 +02:00
|
|
|
return foundKey, nil
|
|
|
|
}
|