package keymgr import ( "fmt" "os" "os/exec" "path/filepath" "ssh-access-tui/internal/model" ) // BuildResult describes one user's finished delivery archive. type BuildResult struct { Username string ArchivePath string Fingerprint string PublicKeyLine string // capture before the scratch dir (and its .pub file) is wiped } // BuildUserArchive generates a fresh keypair for username, writes the // client config and Windows instructions alongside it in a scratch // directory, packs everything into a single AES-256-encrypted 7z archive // under outDir, and then wipes the scratch directory (including the // plaintext private key) from disk. password is shared across one export // batch and must be communicated to the recipient out-of-band. func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, hosts []model.Host) (*BuildResult, error) { userScratch := filepath.Join(scratchDir, username) if err := os.MkdirAll(userScratch, 0o700); err != nil { return nil, fmt.Errorf("create scratch dir: %w", err) } defer Wipe(userScratch) kp, err := GeneratePair(userScratch, username) if err != nil { return nil, fmt.Errorf("generate keypair: %w", err) } keyFileName := filepath.Base(kp.PrivateKeyPath) cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, hosts) if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil { return nil, fmt.Errorf("write config: %w", err) } instr := RenderInstructions(username, jumpHost, keyFileName, hosts) if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil { return nil, fmt.Errorf("write instructions: %w", err) } if err := os.MkdirAll(outDir, 0o750); err != nil { return nil, fmt.Errorf("create output dir: %w", err) } archivePath := filepath.Join(outDir, username+"_ssh_access.7z") _ = os.Remove(archivePath) // 7z refuses to overwrite silently-appends; start clean cmd := exec.Command("7z", "a", "-p"+password, "-mhe=on", // encrypt file names/headers too, not just contents "-mx=9", archivePath, filepath.Join(userScratch, keyFileName), filepath.Join(userScratch, keyFileName+".pub"), filepath.Join(userScratch, "config"), filepath.Join(userScratch, "INSTRUCTIONS.txt"), ) if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("7z: %w: %s", err, out) } return &BuildResult{ Username: username, ArchivePath: archivePath, Fingerprint: kp.Fingerprint, PublicKeyLine: kp.PublicKeyLine, }, nil } // Wipe securely removes a scratch directory (best-effort shred of file // contents, then unlink) so the private key does not linger in free disk // space or in any backup that might sweep the tool's working directory. func Wipe(dir string) { _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } _ = exec.Command("shred", "-u", "-n", "1", path).Run() return nil }) _ = os.RemoveAll(dir) }