sipeed/picoclaw · error

credential: cannot read SSH key %q: %w

Error message

credential: cannot read SSH key %q: %w

What it means

deriveKey passed the whitelist check but os.ReadFile(sshKeyPath) still failed. The original *PathError is wrapped with %w, so errors.Is(err, fs.ErrNotExist) / os.IsPermission(err) work on the returned error. This is a plain filesystem failure at the moment of reading, distinct from the path-policy error at line 294.

Source

Thrown at pkg/credential/credential.go:301

//
// 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)

	key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen)
	if err != nil {
		return nil, fmt.Errorf("credential: HKDF expand failed: %w", err)
	}
	return key, nil
}

// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption.
//
// Priority:
//  1. override (non-empty explicit argument)
//  2. PICOCLAW_SSH_KEY_PATH env var

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Inspect the path: `ls -l <path>` - confirm it exists and shows mode 0600
  2. Fix ownership/permissions to the daemon user: `chown <svcuser> <key> && chmod 600 <key>`
  3. If the key is genuinely gone, regenerate it (credential.GenerateSSHKey) and re-encrypt credentials encrypted with the old key - old blobs will no longer decrypt
  4. If HOME or the env var points somewhere unexpected, re-check PICOCLAW_SSH_KEY_PATH / $HOME for the service context

Example fix

// before: assumes key is readable
enc, err := credential.Encrypt(pass, keyPath, secret)

// after: pre-flight read check with a precise diagnosis
if fi, err := os.Stat(keyPath); err != nil {
    return fmt.Errorf("key path unusable: %w", err)
} else if fi.Mode().Perm()&0o077 != 0 {
    return fmt.Errorf("key %s is group/world accessible; chmod 600", keyPath)
}
enc, err := credential.Encrypt(pass, keyPath, secret)
Defensive patterns

Strategy: validation

Validate before calling

func keyReadable(path string) error {
    fi, err := os.Stat(path)
    if err != nil {
        return fmt.Errorf("SSH key stat: %w", err)
    }
    if fi.IsDir() {
        return fmt.Errorf("SSH key path %s is a directory", path)
    }
    if fi.Mode().Perm()&0o400 == 0 {
        return fmt.Errorf("SSH key %s not readable by uid %d", path, os.Getuid())
    }
    return nil
}

Type guard

import "io/fs"

func classifyKeyReadError(err error) string {
    switch {
    case errors.Is(err, fs.ErrNotExist):
        return "missing"
    case errors.Is(err, fs.ErrPermission):
        return "permission"
    default:
        return "other"
    }
}

Try / catch

if _, err := credential.Encrypt(pass, keyPath, secret); err != nil {
    var keyErr error
    if strings.Contains(err.Error(), "cannot read SSH key") {
        keyErr = errors.Unwrap(errors.Unwrap(err)) // *fs.PathError
    }
    // decide: regenerate key (missing) vs fix perms (permission) vs abort
}

Prevention

When it happens

Trigger: Encrypt or decrypt when the key file is deleted or renamed between path selection and read; key mode 0600 owned by a different uid (EACCES) - typical when the daemon runs as root but the key belongs to a user; path points at a directory; dangling symlink; key on an unmounted network volume.

Common situations: Service user changed after setup (systemd User= directive added); key regenerated for another user; NFS/home unmounted on a laptop; another admin rotated keys and removed the old file; containers where the key volume is mounted read-only at a different uid.

Related errors


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