sipeed/picoclaw · error

credential: SSH private key is required but not found (set P

Error message

credential: SSH private key is required but not found (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)

What it means

deriveKey requires a non-empty sshKeyPath because the AES key is derived from HMAC(SHA256(sshKeyBytes), passphrase). Encrypt/resolveEncrypted resolve the path via pickSSHKeyPath (explicit argument, then PICOCLAW_SSH_KEY_PATH, then auto-detected ~/.ssh/picoclaw_ed25519.key). This error means all three sources came up empty, so no KDF input beyond the passphrase exists and the library refuses to encrypt or decrypt.

Source

Thrown at pkg/credential/credential.go:289

	// Within ~/.ssh/.
	if userHome, err := os.UserHomeDir(); err == nil {
		if isWithinDir(clean, filepath.Join(userHome, ".ssh")) {
			return true
		}
	}

	return false
}

// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key.
//
// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase)
// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
// sshKeyPath must be non-empty; returns an error otherwise.
func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) {
	if sshKeyPath == "" {
		return nil, fmt.Errorf(
			"credential: SSH private key is required but not found" +
				" (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)")
	}
	if !allowedSSHKeyPath(sshKeyPath) {
		return nil, fmt.Errorf(
			"credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)",
			sshKeyPath,
		)
	}
	sshBytes, err := os.ReadFile(sshKeyPath)
	if err != nil {
		return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err)
	}
	sshHash := sha256.Sum256(sshBytes)
	mac := hmac.New(sha256.New, sshHash[:])
	mac.Write([]byte(passphrase))
	ikm := mac.Sum(nil)

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Generate the key: run the picoclaw keygen command or call credential.GenerateSSHKey on the default path (creates ~/.ssh/picoclaw_ed25519.key with 0600)
  2. Or point to an existing key: export PICOCLAW_SSH_KEY_PATH=/path/to/private_key in the daemon's environment
  3. If the key was created under another account, copy it to the running user's ~/.ssh/picoclaw_ed25519.key (keep 0600)
  4. Check `echo $HOME` for the service user and that $HOME/.ssh is where you think it is

Example fix

// before: Encrypt on a machine with no key configured
enc, err := credential.Encrypt(passphrase, "", secret)

// after: bootstrap the key once, then encrypt
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
    keyPath, err = credential.DefaultSSHKeyPath()
    if err != nil {
        return err
    }
    if err := credential.GenerateSSHKey(keyPath); err != nil {
        return err
    }
}
enc, err := credential.Encrypt(passphrase, keyPath, secret)
Defensive patterns

Strategy: validation

Validate before calling

func ensureSSHKey() (string, error) {
    if p := os.Getenv("PICOCLAW_SSH_KEY_PATH"); p != "" {
        if _, err := os.Stat(p); err != nil {
            return "", fmt.Errorf("configured key missing: %w", err)
        }
        return p, nil
    }
    p, err := credential.DefaultSSHKeyPath()
    if err != nil {
        return "", err
    }
    if _, err := os.Stat(p); os.IsNotExist(err) {
        if err := credential.GenerateSSHKey(p); err != nil {
            return "", err
        }
    }
    return p, nil
}

Try / catch

if _, err := credential.Encrypt(pass, keyPath, secret); err != nil {
    if strings.Contains(err.Error(), "SSH private key is required") {
        // bootstrap: generate the default key and retry once
        if p, gerr := credential.DefaultSSHKeyPath(); gerr == nil {
            if gerr = credential.GenerateSSHKey(p); gerr == nil {
                _, err = credential.Encrypt(pass, p, secret)
            }
        }
    }
    if err != nil {
        return err
    }
}

Prevention

When it happens

Trigger: Calling Encrypt (or resolving an ENC- credential) when PICOCLAW_SSH_KEY_PATH is unset AND ~/.ssh/picoclaw_ed25519.key does not exist (findDefaultSSHKey's os.Stat fails). Also fires when PICOCLAW_SSH_KEY_PATH is explicitly exported as the empty string - pickSSHKeyPath honors the set-but-empty value and returns "".

Common situations: Fresh install where the keygen bootstrap step was never run; daemon running as root while the key was generated for a normal user (HOME points to /root); containers or CI images with no ~/.ssh; HOME unset so DefaultSSHKeyPath fails inside findDefaultSSHKey; key deleted after credentials were created.

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/4e926f4df713bffd. Report an issue: GitHub.