semaphoreui/semaphore · critical

encryption key id not found in keyset (the key encrypting…

Error message

encryption key id %q not found in keyset (the key encrypting this value is missing)

What it means

keyset.decrypt parses the stored envelope for a key id and looks the id up in its key map. When the envelope carries a key id that is absent from the keyset, decryption cannot proceed and this error is returned. It is the keyring's way of saying: this value was encrypted by a key that is no longer configured.

Solutions

  1. Add the missing key (the id is named in the error) back to the keyset configuration so the value can be decrypted.
  2. Run the rekey flow once the old key is restored to move values onto the current primary key.
  3. Audit key env/config parity between environments that share a database.

Example fix

// before
// keyset: [key-2]; stored envelope: v1:key-1:...
// -> "encryption key id \"key-1\" not found"
// after
// keyset: [key-1 (retained for decryption), key-2 (primary)]
// then rekey values so envelopes reference key-2
Defensive patterns

Strategy: validation

Validate before calling

id, _, hasID := util.ParseEnvelope(stored)
if hasID && !keyset.HasID(id) {
    return fmt.Errorf("key %q missing from keyset; restore it before decrypting", id)
}

Type guard

func hasKeyFor(stored string, ids map[string][]byte) bool {
    id, _, hasID := util.ParseEnvelope(stored)
    return !hasID || (ids != nil && ids[id] != nil)
}

Try / catch

plaintext, err := keysetDecrypt(stored)
if err != nil && strings.Contains(err.Error(), "not found in keyset") {
    // extract id from message, restore that key to config, then retry
    return fmt.Errorf("restore the named key to the keyset before proceeding: %w", err)
}

Prevention

When it happens

Trigger: Any keyring decrypt (options, JWT signing key, etc.) where the stored envelope's key id ("v1:<id>:<ciphertext>" form) is not present in k.byID - keys rotated/removed from config, or data copied between environments with different keysets.

Common situations: After key rotation without rekeying stored values; restoring a DB dump into an environment that lacks the original encryption keys; sharing a database between Semaphore instances configured with different key sets.

Related errors


AI-assisted analysis of semaphoreui/semaphore@1774ccb71a (2026-09-07). Data as JSON: /api/errors/e55b2c294fb81aa1. Report an issue: GitHub.

Appendix: source

Thrown at util/keyring.go:133

// fallback for values written before the option/access split.
func (conf *ConfigType) DecryptOption(stored string) ([]byte, error) {
	ks := conf.currentKeyset()
	return ks.decrypt(stored, ks.legacyOptionCandidates())
}

// DecryptAccessSecretWithKey decrypts a stored secret with a single explicit key,
// stripping any id prefix. Used by the rekey `--old-key` path.
func (conf *ConfigType) DecryptAccessSecretWithKey(stored, key string) ([]byte, error) {
	_, ct, _ := parseEnvelope(stored)
	return DecryptAESGCM(ct, key)
}

func (k *keyset) decrypt(stored string, legacy []string) ([]byte, error) {
	id, ct, hasID := parseEnvelope(stored)
	if hasID {
		material, ok := k.byID[id]
		if !ok {
			return nil, fmt.Errorf("encryption key id %q not found in keyset (the key encrypting this value is missing)", id)
		}
		return DecryptAESGCM(ct, material)
	}
	return decryptWithKeys(ct, legacy)
}

// legacyAccessCandidates returns the keys to trial-decrypt an un-prefixed access
// secret: the flat access key first, then every registry key. The empty
// (passthrough) key is excluded unless there are no real keys at all, so a real
// ciphertext is never "successfully" decrypted to garbage by the empty key.
func (k *keyset) legacyAccessCandidates() []string {
	return k.legacyCandidates(k.legacyAccess)
}

// legacyOptionCandidates is like legacyAccessCandidates but tries the flat option
// key, then the flat access key, then the registry.
func (k *keyset) legacyOptionCandidates() []string {
	return k.legacyCandidates(k.legacyOption, k.legacyAccess)

View on GitHub (pinned to 1774ccb71a)