{"record":{"id":"6e65c9d3139942af","repo":"sipeed/picoclaw","slug":"credential-cannot-read-ssh-key-q-w","errorCode":null,"errorMessage":"credential: cannot read SSH key %q: %w","messagePattern":"credential: cannot read SSH key %q: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pkg/credential/credential.go","lineNumber":301,"sourceCode":"//\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\n}\n\n// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption.\n//\n// Priority:\n//  1. override (non-empty explicit argument)\n//  2. PICOCLAW_SSH_KEY_PATH env var","sourceCodeStart":283,"sourceCodeEnd":319,"githubUrl":"https://github.com/sipeed/picoclaw/blob/49183d7e8daed0dba89ddbb6fcb60089401d9680/pkg/credential/credential.go#L283-L319","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Inspect the path: `ls -l <path>` - confirm it exists and shows mode 0600","Fix ownership/permissions to the daemon user: `chown <svcuser> <key> && chmod 600 <key>`","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","If HOME or the env var points somewhere unexpected, re-check PICOCLAW_SSH_KEY_PATH / $HOME for the service context"],"exampleFix":"// before: assumes key is readable\nenc, err := credential.Encrypt(pass, keyPath, secret)\n\n// after: pre-flight read check with a precise diagnosis\nif fi, err := os.Stat(keyPath); err != nil {\n    return fmt.Errorf(\"key path unusable: %w\", err)\n} else if fi.Mode().Perm()&0o077 != 0 {\n    return fmt.Errorf(\"key %s is group/world accessible; chmod 600\", keyPath)\n}\nenc, err := credential.Encrypt(pass, keyPath, secret)","handlingStrategy":"validation","validationCode":"func keyReadable(path string) error {\n    fi, err := os.Stat(path)\n    if err != nil {\n        return fmt.Errorf(\"SSH key stat: %w\", err)\n    }\n    if fi.IsDir() {\n        return fmt.Errorf(\"SSH key path %s is a directory\", path)\n    }\n    if fi.Mode().Perm()&0o400 == 0 {\n        return fmt.Errorf(\"SSH key %s not readable by uid %d\", path, os.Getuid())\n    }\n    return nil\n}","typeGuard":"import \"io/fs\"\n\nfunc classifyKeyReadError(err error) string {\n    switch {\n    case errors.Is(err, fs.ErrNotExist):\n        return \"missing\"\n    case errors.Is(err, fs.ErrPermission):\n        return \"permission\"\n    default:\n        return \"other\"\n    }\n}","tryCatchPattern":"if _, err := credential.Encrypt(pass, keyPath, secret); err != nil {\n    var keyErr error\n    if strings.Contains(err.Error(), \"cannot read SSH key\") {\n        keyErr = errors.Unwrap(errors.Unwrap(err)) // *fs.PathError\n    }\n    // decide: regenerate key (missing) vs fix perms (permission) vs abort\n}","preventionTips":["Keep key files 0600 and owned by the uid running the daemon","Re-run keygen on hosts where the key was lost, then re-encrypt dependent credentials","Audit key files after user/uid changes in systemd units","Avoid network home directories for key material where unmounts are common"],"tags":["filesystem","permissions","ssh","credentials","deployment"],"backgroundTag":null,"analyzedSha":"49183d7e8daed0dba89ddbb6fcb60089401d9680","analyzedAt":"2026-08-15T21:55:41.315Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}