{"record":{"id":"8a2c590fde21232e","repo":"sipeed/picoclaw","slug":"credential-ssh-key-path-q-is-not-in-an-allowed-l","errorCode":null,"errorMessage":"credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)","messagePattern":"credential: SSH key path %q is not in an allowed location \\(PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/\\.ssh/\\)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pkg/credential/credential.go","lineNumber":294,"sourceCode":"\t\t}\n\t}\n\n\treturn false\n}\n\n// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key.\n//\n// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase)\n// Final key: HKDF-SHA256(ikm, salt, info=\"picoclaw-credential-v1\", 32 bytes)\n// sshKeyPath must be non-empty; returns an error otherwise.\nfunc deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) {\n\tif sshKeyPath == \"\" {\n\t\treturn nil, fmt.Errorf(\n\t\t\t\"credential: SSH private key is required but not found\" +\n\t\t\t\t\" (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)\")\n\t}\n\tif !allowedSSHKeyPath(sshKeyPath) {\n\t\treturn nil, fmt.Errorf(\n\t\t\t\"credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)\",\n\t\t\tsshKeyPath,\n\t\t)\n\t}\n\tsshBytes, err := os.ReadFile(sshKeyPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"credential: cannot read SSH key %q: %w\", sshKeyPath, err)\n\t}\n\tsshHash := sha256.Sum256(sshBytes)\n\tmac := hmac.New(sha256.New, sshHash[:])\n\tmac.Write([]byte(passphrase))\n\tikm := mac.Sum(nil)\n\n\tkey, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"credential: HKDF expand failed: %w\", err)\n\t}\n\treturn key, nil","sourceCodeStart":276,"sourceCodeEnd":312,"githubUrl":"https://github.com/sipeed/picoclaw/blob/49183d7e8daed0dba89ddbb6fcb60089401d9680/pkg/credential/credential.go#L276-L312","documentation":"Before reading the key file, deriveKey enforces allowedSSHKeyPath: the path must exactly match PICOCLAW_SSH_KEY_PATH, or sit inside PICOCLAW_HOME, or sit inside ~/.ssh/. Any other path is rejected without a file read. This is a deliberate security control (path whitelist) so key material never gets mixed from arbitrary attacker-influenced locations.","triggerScenarios":"Passing an explicit sshKeyPath to Encrypt that lives outside the whitelist (e.g. /tmp/key, /etc/picoclaw/key, ./keys/id_ed25519 relative to cwd); or setting PICOCLAW_HOME to a directory that does not contain the key you pass. Note allowedSSHKeyPath compares filepath.Clean values, so traversal tricks and unclean paths still get normalized before the check.","commonSituations":"Storing the key next to the app binary or in /var/lib/<app>/ and passing that path directly; team workflows that keep keys in a secrets dir like /opt/secrets; moving a deployment to a container where the key is mounted at /keys/picoclaw_ed25519.key.","solutions":["Move the key under ~/.ssh/ (e.g. ~/.ssh/picoclaw_ed25519.key, mode 0600) - the simplest always-allowed location","Or export PICOCLAW_SSH_KEY_PATH=/exact/path/to/key - an exact match with the env var is allowed","Or place the key anywhere under PICOCLAW_HOME and keep that env var set for the daemon","Update your code/config to stop passing paths outside these roots; the whitelist is intentional and not bypassable"],"exampleFix":"# before: key outside the whitelist\nexport PICOCLAW_SSH_KEY_PATH=/opt/secrets/picoclaw.key\n# Encrypt(pass, \"/opt/secrets/other.key\", s) -> not in an allowed location\n\n# after: either move the key\nmv /opt/secrets/picoclaw.key ~/.ssh/picoclaw_ed25519.key\n# or make the passed path an exact match of the env var\nexport PICOCLAW_SSH_KEY_PATH=/opt/secrets/picoclaw.key\n# Encrypt(pass, \"/opt/secrets/picoclaw.key\", s) -> allowed","handlingStrategy":"validation","validationCode":"// Mirror of the library's whitelist: exact PICOCLAW_SSH_KEY_PATH match,\n// inside PICOCLAW_HOME, or inside ~/.ssh/.\nfunc keyPathAllowed(path string) bool {\n    if p := os.Getenv(\"PICOCLAW_SSH_KEY_PATH\"); p != \"\" &&\n        filepath.Clean(path) == filepath.Clean(p) {\n        return true\n    }\n    if h := os.Getenv(\"PICOCLAW_HOME\"); h != \"\" {\n        if rel, err := filepath.Rel(filepath.Clean(h), filepath.Clean(path)); err == nil && filepath.IsLocal(rel) {\n            return true\n        }\n    }\n    home, err := os.UserHomeDir()\n    if err != nil {\n        return false\n    }\n    sshDir := filepath.Join(home, \".ssh\")\n    rel, err := filepath.Rel(sshDir, filepath.Clean(path))\n    return err == nil && filepath.IsLocal(rel)\n}","typeGuard":"func isSSHKeyPathRejected(err error) bool {\n    return err != nil && strings.Contains(err.Error(), \"not in an allowed location\")\n}","tryCatchPattern":null,"preventionTips":["Standardize on one key location per fleet (~/.ssh/picoclaw_ed25519.key) and symlink if needed","Never pass ad-hoc /tmp or cwd-relative key paths to Encrypt","Document the three allowed roots wherever deployment docs mention SSH keys","Remember the whitelist is a security control - do not try to work around it in tests by weakening it"],"tags":["security","path-validation","ssh","configuration","credentials"],"backgroundTag":null,"analyzedSha":"49183d7e8daed0dba89ddbb6fcb60089401d9680","analyzedAt":"2026-08-15T21:55:41.315Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}