pwn 0.5.783 → 0.5.785

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'spec_helper'
5
+ require 'pwn/plugins/yubikey'
6
+
7
+ RSpec.describe PWN::Plugins::YubiKey, 'native credential boundaries' do
8
+ before { expect(described_class::Transport).not_to receive(:open) }
9
+
10
+ it 'rejects legacy process settings and unknown parameters without exposing values' do
11
+ %i[ykman stdin input log_file max_output scp scp_ca scp_sd scp_oce arbitrary].each do |key|
12
+ expect do
13
+ described_class.command({ args: %w[otp info], key => 'synthetic-sensitive-value' })
14
+ end.to raise_error(ArgumentError, /[Uu]nknown|[Uu]nsupported/) { |error|
15
+ expect(error.full_message).not_to include('synthetic-sensitive-value')
16
+ }
17
+ end
18
+ end
19
+
20
+ it 'validates native selectors before opening anything' do
21
+ { serial: ['', 123, ' 123', '123x', "123\n", false],
22
+ reader: ['', false, "reader\n"], device_path: ['', false, "path\0"],
23
+ timeout: [0, -1, Float::NAN, Float::INFINITY, '30', nil, false], connection: [false, Object.new] }.each do |key, values|
24
+ values.each do |value|
25
+ expect { described_class.command({ args: %w[otp info], key => value }) }.to raise_error(ArgumentError)
26
+ end
27
+ end
28
+ end
29
+
30
+ it 'requires literal consent for mutations and authentication regardless of force flags' do
31
+ [%w[otp delete 2 --force], %w[fido reset], %w[piv access change-pin], %w[oath accounts code account],
32
+ %w[sd reset], %w[config usb --list --disable OTP], %w[hsmauth credentials generate name]].each do |args|
33
+ [nil, false, 'true', 1].each do |confirm|
34
+ expect { described_class.command(args: args, confirm: confirm) }.to raise_error(described_class::ConfirmationRequired)
35
+ end
36
+ end
37
+ end
38
+
39
+ it 'rejects secret argv including inline flags and surplus positional credentials' do
40
+ [%w[piv info --pin fixture-secret], %w[piv info --pin=fixture-secret],
41
+ %w[fido access change-pin -nfixture-secret], %w[otp --access-code fixture-secret info],
42
+ %w[otp chalresp 2 fixture-secret], %w[otp static 2 fixture-secret],
43
+ %w[oath accounts add account fixture-secret], %w[oath accounts uri otpauth://fixture-secret],
44
+ %w[sd keys import 1 1 fixture-secret], %w[info --log-file fixture-secret],
45
+ %w[future reset fixture-secret]].each do |args|
46
+ expect do
47
+ described_class.command(args: args, confirm: true)
48
+ end.to raise_error(ArgumentError) { |error| expect(error.full_message).not_to include('fixture-secret') }
49
+ end
50
+ end
51
+
52
+ it 'rejects malformed argv without coercion or control characters' do
53
+ ['info', [], [nil], [false], ["otp\0", 'info'], ['otp', 'info', 'x' * 65_537]].each do |args|
54
+ expect { described_class.command(args: args) }.to raise_error(ArgumentError)
55
+ end
56
+ end
57
+ end
58
+
59
+ RSpec.describe PWN::Plugins::YubiKey, 'native connection lifetime' do
60
+ let(:connection) { double('native connection', descriptor: { protocol: :apdu }, exchange: ''.b) }
61
+
62
+ [IOError, Interrupt, PWN::Plugins::YubiKey::TimeoutError].each do |error_class|
63
+ it "closes its owned connection on #{error_class}" do
64
+ expect(described_class::Transport).to receive(:open).and_return(connection)
65
+ expect(connection).to receive(:close).once
66
+ expect(described_class::OTP).to receive(:execute) do |params|
67
+ params[:connection].descriptor
68
+ raise error_class, 'fixture failure'
69
+ end
70
+ expected = error_class == IOError ? described_class::CommandError : error_class
71
+ expect { described_class.otp(args: ['info']) }.to raise_error(expected)
72
+ end
73
+ end
74
+
75
+ it 'never closes a borrowed connection even on an APDU failure' do
76
+ expect(connection).not_to receive(:close)
77
+ expect(connection).to receive(:exchange).and_return(['6982'].pack('H*'))
78
+ expect do
79
+ described_class.oath(args: ['info'], connection: connection)
80
+ end.to raise_error(described_class::Protocol::APDUError) { |error| expect(error.status).to eq(0x6982) }
81
+ end
82
+
83
+ it 'suppresses native IO diagnostic secrets and exception causes' do
84
+ expect(described_class::Transport).to receive(:open).and_raise(IOError, 'synthetic-sensitive-path')
85
+ expect do
86
+ described_class.otp(args: ['info'])
87
+ end.to raise_error(described_class::CommandError) { |error|
88
+ expect(error.full_message).not_to include('synthetic-sensitive-path')
89
+ expect(error.cause).to be_nil
90
+ }
91
+ end
92
+
93
+ [false, true].each do |owned|
94
+ it "wraps an explicit secure channel and preserves #{owned ? 'owned cleanup' : 'caller ownership'}" do
95
+ options = { scp: :scp03, kvn: 7, key_enc: 'e' * 16, key_mac: 'm' * 16 }.freeze
96
+ wrapper = double('secure connection', exchange: ''.b)
97
+ expect(described_class::Transport).to receive(:open).and_return(connection) if owned
98
+ if owned
99
+ expect(connection).to receive(:close).once
100
+ else
101
+ expect(connection).not_to receive(:close)
102
+ end
103
+ expect(described_class::SecurityDomain).to receive(:secure_connection) do |params|
104
+ expect(params).to include(options)
105
+ expect(params[:confirm]).to be(true)
106
+ expect(params[:connection].descriptor).to eq(protocol: :apdu)
107
+ params[:connection].close # SCP invalidation must not close the borrowed handle.
108
+ wrapper
109
+ end
110
+ expect(wrapper).to receive(:close).once
111
+ expect(described_class::OATH).to receive(:execute).with(hash_including(connection: wrapper, args: ['info'], confirm: true)).and_return(:secure_result)
112
+ selected = owned ? {} : { connection: connection }
113
+ expect(described_class.oath(selected.merge(args: ['info'], confirm: true, secure_channel: options))).to eq(:secure_result)
114
+ expect(options).not_to have_key(:connection)
115
+ end
116
+ end
117
+
118
+ it 'never falls back to plaintext after secure-channel authentication failure' do
119
+ expect(described_class::Transport).to receive(:open).and_return(connection)
120
+ expect(connection).to receive(:close).once
121
+ expect(described_class::SecurityDomain).to receive(:secure_connection).and_raise(described_class::ResponseError, 'SCP authentication failed')
122
+ expect(described_class::OATH).not_to receive(:execute)
123
+ expect do
124
+ described_class.oath(args: ['info'], confirm: true, secure_channel: { scp: :scp03, kvn: 1, key_enc: 'e' * 16, key_mac: 'm' * 16 })
125
+ end.to raise_error(described_class::ResponseError, /SCP/)
126
+ end
127
+
128
+ it 'validates secure-channel options and explicit consent before opening hardware' do
129
+ expect(described_class::Transport).not_to receive(:open)
130
+ [false, 'scp03', {}, { scp: :scp03, confirm: true }, { connection: connection }].each do |value|
131
+ expect { described_class.oath(args: ['info'], confirm: true, secure_channel: value) }.to raise_error(ArgumentError)
132
+ end
133
+ expect do
134
+ described_class.oath(args: ['info'], secure_channel: { scp: :scp03, kvn: 1, key_enc: 'e' * 16, key_mac: 'm' * 16 })
135
+ end.to raise_error(described_class::ConfirmationRequired)
136
+ end
137
+
138
+ it 'loads the facade in a fresh Ruby without process libraries or device discovery' do
139
+ source = <<~RUBY
140
+ before = $LOADED_FEATURES.dup
141
+ require 'pwn/plugins/yubikey'
142
+ added = $LOADED_FEATURES - before
143
+ abort 'process library loaded' if added.any? { |path| path.match?(%r{/(?:open3|pty)(?:\\.|/)}) }
144
+ abort 'native transport eagerly loaded' unless PWN::Plugins::YubiKey.autoload?(:Transport)
145
+ puts 'native-only'
146
+ RUBY
147
+ output = IO.popen([RbConfig.ruby, '-Ilib', '-e', source], &:read)
148
+ expect($CHILD_STATUS).to be_success
149
+ expect(output).to eq("native-only\n")
150
+ text = File.read(File.expand_path('../../../../lib/pwn/plugins/yubikey.rb', __dir__))
151
+ expect(text).not_to match(/Open3|PTY|\b(?:system|spawn|popen|exec)\s*\(/)
152
+ end
153
+ end
154
+
155
+ RSpec.describe PWN::Plugins::YubiKey, 'total native operation deadline' do
156
+ before do
157
+ @now = 100.0
158
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { @now }
159
+ end
160
+
161
+ it 'shares one budget across acquisition, OATH SELECT, LIST and calculation' do
162
+ connection = double('APDU transport', descriptor: { protocol: :apdu })
163
+ expect(described_class::Transport).to receive(:open).with(hash_including(application: :oath, timeout: 3)) do
164
+ @now += 1
165
+ connection
166
+ end
167
+ expect(connection).to receive(:close).once
168
+ expect(connection).to receive(:exchange).with(hash_including(data: ['00a4040007a0000005272101'].pack('H*'), timeout: 2)).ordered do
169
+ @now += 1
170
+ ['79030507017101739000'].pack('H*')
171
+ end
172
+ expect(connection).to receive(:exchange).with(hash_including(data: ['00a1000000'].pack('H*'), timeout: 1)).ordered do
173
+ @now += 2
174
+ ['720221619000'].pack('H*')
175
+ end
176
+ expect do
177
+ described_class.oath_code(account: 'a', confirm: true, timeout: 3, timestamp: 59)
178
+ end.to raise_error(described_class::TimeoutError)
179
+ end
180
+
181
+ it 'charges secure-channel authentication to the same budget on a borrowed connection' do
182
+ connection = double('APDU transport', descriptor: { protocol: :apdu })
183
+ expect(connection).not_to receive(:close)
184
+ expect(described_class::SecurityDomain).to receive(:secure_connection) do |params|
185
+ expect(params[:timeout]).to eq(3)
186
+ @now += 2
187
+ params[:connection]
188
+ end
189
+ expect(connection).to receive(:exchange).with(hash_including(timeout: 1)).once do
190
+ @now += 2
191
+ ['79030507017101739000'].pack('H*')
192
+ end
193
+ expect do
194
+ described_class.oath(args: ['info'], connection: connection, confirm: true, timeout: 3,
195
+ secure_channel: { scp: :scp03, kvn: 1, key_enc: 'e' * 16, key_mac: 'm' * 16 })
196
+ end.to raise_error(described_class::TimeoutError)
197
+ end
198
+
199
+ it 'refuses exchanges when acquisition exhausts the budget and still closes the owned handle' do
200
+ connection = double('late native transport', descriptor: { protocol: :otp })
201
+ expect(described_class::Transport).to receive(:open) do
202
+ @now += 4
203
+ connection
204
+ end
205
+ expect(connection).not_to receive(:exchange)
206
+ expect(connection).to receive(:close).once
207
+ expect { described_class.otp(args: ['info'], timeout: 3) }.to raise_error(described_class::TimeoutError)
208
+ end
209
+ end
210
+
211
+ RSpec.describe PWN::Plugins::YubiKey, 'validation before native acquisition' do
212
+ it 'does not open a device for missing PIV credentials or an invalid native slot' do
213
+ expect(described_class::Transport).not_to receive(:open)
214
+ expect { described_class.piv(args: %w[access change-pin], confirm: true) }.to raise_error(ArgumentError)
215
+ expect { described_class.otp(args: %w[delete invalid-slot], confirm: true) }.to raise_error(ArgumentError)
216
+ end
217
+
218
+ it 'runs explicit cache-only forget without discovering or opening a YubiKey' do
219
+ cache = double('caller encrypted cache', encrypted?: true, clear: nil, empty?: true)
220
+ expect(described_class::Transport).not_to receive(:open)
221
+ expect(described_class::Transport).not_to receive(:devices)
222
+ expect(cache).to receive(:clear).once
223
+ expect(described_class.oath(args: %w[access forget --all], confirm: true, encrypted_cache: cache)).to eq(forgotten: :all, verified: true)
224
+ end
225
+
226
+ it 'allows native read-only inventory without authorizing authentication or changes' do
227
+ connection = double('caller connection', exchange: nil)
228
+ expect(described_class::Management).to receive(:execute).with(hash_including(args: %w[usb --list])).and_return(:inventory)
229
+ expect(described_class.config(args: %w[usb --list], connection: connection)).to eq(:inventory)
230
+ expect(described_class::HSMAuth).to receive(:execute).with(hash_including(args: %w[credentials list])).and_return([])
231
+ expect(described_class.hsmauth(args: %w[credentials list], connection: connection)).to eq([])
232
+ expect do
233
+ described_class.config(args: %w[usb --list], disable: 'OTP', connection: connection)
234
+ end.to raise_error(described_class::ConfirmationRequired)
235
+ end
236
+
237
+ it 'keeps OATH list read-only only when no authentication or cache state is requested' do
238
+ connection = double('caller connection', descriptor: { protocol: :apdu })
239
+ expect(connection).to receive(:exchange).with(hash_including(data: ['00a4040007a0000005272101'].pack('H*'))).ordered.and_return(['79030507017101739000'].pack('H*'))
240
+ expect(connection).to receive(:exchange).with(hash_including(data: ['00a1000000'].pack('H*'))).ordered.and_return(['9000'].pack('H*'))
241
+ expect(described_class.oath(args: %w[accounts list], connection: connection)).to eq([])
242
+ expect do
243
+ described_class.oath(args: %w[accounts list], password: 'synthetic', connection: connection)
244
+ end.to raise_error(described_class::ConfirmationRequired)
245
+ end
246
+
247
+ it 'documents every public root API with native behavior and no executable backend' do
248
+ output = StringIO.new
249
+ prior = $stdout
250
+ $stdout = output
251
+ described_class.help
252
+ (described_class.singleton_methods(false) - [:help]).each do |method|
253
+ expect(output.string).to include("PWN::Plugins::YubiKey.#{method}")
254
+ end
255
+ expect(output.string).to include('native', 'secret_provider', 'operator:', 'total')
256
+ expect(output.string).not_to include('wrapper for official', 'executable path')
257
+ ensure
258
+ $stdout = prior
259
+ end
260
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pwn/plugins/yubikey'
5
+
6
+ RSpec.describe PWN::Plugins::YubiKey do
7
+ it 'has no executable dependencies' do
8
+ expect(described_class.required_bins).to eq([])
9
+ end
10
+
11
+ it 'autoloads native children without probing a device' do
12
+ %i[Protocol Transport OTP Management OATH HSMAuth PIV FIDO OpenPGP SecurityDomain].each do |name|
13
+ expect(described_class.const_defined?(name, false)).to be(true)
14
+ end
15
+ end
16
+
17
+ it 'checks host availability without discovery' do
18
+ expect(described_class::Transport).to receive(:available?).with({}).and_return(true)
19
+ expect(described_class::Transport).not_to receive(:devices)
20
+ expect(described_class::Transport).not_to receive(:open)
21
+ expect(described_class.available?).to be(true)
22
+ end
23
+
24
+ it 'returns native descriptors from explicit discovery' do
25
+ records = [{ serial: '001234', protocol: :apdu, reader: 'Fixture NFC' }]
26
+ expect(described_class::Transport).to receive(:devices).with({ serial: '001234', timeout: 3 }).and_return(records)
27
+ expect(described_class.devices(serial: '001234', timeout: 3)).to equal(records)
28
+ end
29
+
30
+ it 'routes OTP info through the actual application and Protocol with owned cleanup' do
31
+ connection = double('native transport', descriptor: { protocol: :apdu }, close: :closed)
32
+ expect(described_class::Transport).to receive(:open) do |opened|
33
+ expect(opened).to include(application: :otp, serial: '001234', reader: 'Fixture NFC')
34
+ expect(opened[:timeout]).to be_positive.and be <= 3
35
+ connection
36
+ end
37
+ expect(connection).to receive(:exchange).with(protocol: :apdu, data: ['00a4040007a0000005272001'].pack('H*'), timeout: kind_of(Numeric)).and_return(['0507010302009000'].pack('H*'))
38
+ expect(connection).to receive(:close).once
39
+ result = described_class.command(args: %w[otp info], serial: '001234', reader: 'Fixture NFC', timeout: 3)
40
+ expect(result).to include(version: [5, 7, 1], programming_sequence: 3)
41
+ expect(result[:slots][2][:configured]).to be(true)
42
+ end
43
+ end
44
+
45
+ RSpec.describe PWN::Plugins::YubiKey, 'native family routing' do
46
+ { otp: ['otp', :OTP, :otp], config: ['config', :Management, :management], oath: ['oath', :OATH, :oath],
47
+ hsmauth: ['hsmauth', :HSMAuth, :hsmauth], piv: ['piv', :PIV, :piv], fido: ['fido', :FIDO, :fido],
48
+ openpgp: ['openpgp', :OpenPGP, :openpgp], security_domain: ['sd', :SecurityDomain, :security_domain] }.each do |method, (prefix, name, application)|
49
+ child = PWN::Plugins::YubiKey.const_get(name)
50
+ it "provides local #{prefix} help without a transport" do
51
+ expect(described_class::Transport).not_to receive(:open)
52
+ expect(described_class::Transport).not_to receive(:devices)
53
+ expect(described_class.public_send(method)).to include(application: application, operations: child.operations)
54
+ child.operations.each do |operation|
55
+ expect(described_class.cli_help(path: [prefix] + operation.split)).to include(operations: [operation])
56
+ end
57
+ end
58
+
59
+ child.operations.each do |operation|
60
+ it "routes #{prefix} #{operation} to the native child without rewriting options" do
61
+ connection = double('caller connection', exchange: nil)
62
+ expect(connection).not_to receive(:close)
63
+ opts = { args: operation.split.freeze, confirm: true, connection: connection, timeout: 4 }.freeze
64
+ expect(child).to receive(:execute).with(hash_including(opts.except(:connection))) do |received|
65
+ expect(received[:connection]).to respond_to(:exchange, :descriptor)
66
+ expect(received.keys).to match_array(opts.keys)
67
+ :native_result
68
+ end
69
+ expect(described_class.public_send(method, opts)).to eq(:native_result)
70
+ expect(opts[:args]).to eq(operation.split)
71
+ end
72
+ end
73
+ end
74
+
75
+ it 'forwards typed credentials and providers by identity, never as argv' do
76
+ connection = double('caller connection', exchange: nil)
77
+ pin = 'synthetic-pin'
78
+ provider = ->(_name) { raise 'unexpected prompt' }
79
+ expect(described_class::PIV).to receive(:execute).with(hash_including(args: %w[access change-pin], confirm: true, pin: pin, new_pin: 'synthetic-new-pin', secret_provider: provider)).and_return(:changed)
80
+ expect(described_class.piv(args: %w[access change-pin], confirm: true, connection: connection, pin: pin, new_pin: 'synthetic-new-pin', secret_provider: provider)).to eq(:changed)
81
+ end
82
+
83
+ it 'calls Management.info for root info without inventing a config subcommand' do
84
+ connection = double('caller connection', exchange: nil)
85
+ expect(described_class::Management).to receive(:info).with(hash_including(connection: respond_to(:exchange))).and_return(version: [5, 7, 1])
86
+ expect(described_class.info(connection: connection)).to eq(version: [5, 7, 1])
87
+ end
88
+
89
+ it 'dispatches root list and info aliases in Ruby' do
90
+ expect(described_class::Transport).to receive(:devices).with({}).and_return([{ serial: '001234' }])
91
+ expect(described_class.command(args: ['list'])).to eq([{ serial: '001234' }])
92
+ connection = double('caller connection', exchange: nil)
93
+ expect(described_class::Management).to receive(:info).with(hash_including(connection: respond_to(:exchange))).and_return(:information)
94
+ expect(described_class.command(args: ['info'], connection: connection)).to eq(:information)
95
+ end
96
+
97
+ it 'rejects arbitrary help paths without opening a device' do
98
+ expect(described_class::Transport).not_to receive(:open)
99
+ [%w[otp delete 2 --force], ['--help'], ['script'], ['future']].each do |path|
100
+ expect { described_class.cli_help(path: path) }.to raise_error(ArgumentError)
101
+ end
102
+ expect(described_class.command(args: ['--help'])).to include(backend: :native)
103
+ end
104
+ end
105
+
106
+ RSpec.describe PWN::Plugins::YubiKey, 'native Vault and MFA helpers' do
107
+ let(:connection) { double('native connection', descriptor: { protocol: :otp }, close: :closed) }
108
+
109
+ it 'returns the raw RFC 2202 HMAC from the native OTP exchange' do
110
+ expect(described_class::Transport).not_to receive(:open)
111
+ expect(connection).not_to receive(:close)
112
+ expect(connection).to receive(:exchange).with(protocol: :otp, command: 0, data: ''.b, response_length: 6, timeout: a_value_between(0, 3)).and_return(['050701030200'].pack('H*'))
113
+ expect(connection).to receive(:exchange).with(protocol: :otp, command: 0x38, data: 'Hi There'.b.ljust(64, "\0"), response_length: 20, timeout: a_value_between(0, 3)).and_return(['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*'))
114
+ opts = { challenge: 'Hi There', connection: connection, timeout: 3, confirm: true }.freeze
115
+ result = described_class.challenge_response(opts)
116
+ expect(result.unpack1('H*')).to eq('b617318655057264e28bc0b6fb378c8ef146be00')
117
+ expect(result.encoding).to eq(Encoding::BINARY)
118
+ expect(opts.keys).not_to include(:slot)
119
+ end
120
+
121
+ it 'programs and verifies a slot through the real OTP application without subprocesses' do
122
+ key = ['0b' * 20].pack('H*').freeze
123
+ before = ['050701030200'].pack('H*')
124
+ after = ['050701040200'].pack('H*')
125
+ expect(connection).to receive(:exchange).with(hash_including(command: 0)).ordered.and_return(before)
126
+ expect(connection).to receive(:exchange).with(hash_including(command: 3)).ordered do |request|
127
+ expect(request[:data].bytesize).to eq(58)
128
+ expect(request[:data].byteslice(22, 16)).to eq(key.byteslice(0, 16))
129
+ ''.b
130
+ end
131
+ expect(connection).to receive(:exchange).with(hash_including(command: 0)).ordered.and_return(after)
132
+ expect(connection).to receive(:exchange).with(hash_including(command: 0)).ordered.and_return(after)
133
+ expect(connection).to receive(:exchange).with(hash_including(command: 0x38)).ordered.and_return(['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*'))
134
+ expect(described_class.provision_challenge_response(key: key, connection: connection, serial: '001234', confirm: true, verification_challenge: 'Hi There')).to eq(:verified)
135
+ end
136
+
137
+ it 'validates helper arguments before opening a transport' do
138
+ expect(described_class::Transport).not_to receive(:open)
139
+ [nil, '', 'x' * 65, [], 42].each do |challenge|
140
+ expect { described_class.challenge_response(challenge: challenge, confirm: true) }.to raise_error(ArgumentError)
141
+ end
142
+ [0, 3, '2', 2.0, false].each do |slot|
143
+ expect { described_class.challenge_response(challenge: 'x', slot: slot, confirm: true) }.to raise_error(ArgumentError)
144
+ end
145
+ [nil, '', "bad\n", 'x' * 65].each do |account|
146
+ expect { described_class.oath_code(account: account, confirm: true) }.to raise_error(ArgumentError)
147
+ end
148
+ expect { described_class.oath_code(account: 'fixture', timestamp: -1, confirm: true) }.to raise_error(ArgumentError)
149
+ expect { described_class.provision_challenge_response(key: 'k' * 19, serial: '001234', confirm: true) }.to raise_error(ArgumentError)
150
+ expect { described_class.provision_challenge_response(key: 'k' * 20, confirm: true) }.to raise_error(ArgumentError)
151
+ end
152
+
153
+ it 'requires consent for provisioning and OATH calculation before opening a device' do
154
+ expect(described_class::Transport).not_to receive(:open)
155
+ [false, nil, 'true'].each do |confirm|
156
+ expect { described_class.oath_code(account: 'fixture', confirm: confirm) }.to raise_error(described_class::ConfirmationRequired)
157
+ expect { described_class.provision_challenge_response(key: 'k' * 20, serial: '001234', confirm: confirm) }.to raise_error(described_class::ConfirmationRequired)
158
+ end
159
+ end
160
+
161
+ it 'calculates an existing-slot HMAC without a mutation consent flag' do
162
+ connection = double('offline OTP', descriptor: { protocol: :otp }, close: :closed)
163
+ allow(described_class::Transport).to receive(:open).and_return(connection)
164
+ allow(described_class::OTP).to receive(:challenge_response).and_return('r'.b * 20)
165
+ expect(described_class.challenge_response(challenge: 'fixture')).to eq('r'.b * 20)
166
+ end
167
+ end
168
+
169
+ RSpec.describe PWN::Plugins::YubiKey, 'explicit native APDUs' do
170
+ let(:connection) { double('smartcard connection', descriptor: { protocol: :apdu }, close: :closed) }
171
+
172
+ it 'encodes typed APDUs through Protocol without putting payloads into argv' do
173
+ expect(connection).not_to receive(:close)
174
+ expect(connection).to receive(:exchange).with(protocol: :apdu, data: ['002000800431323334'].pack('H*'), timeout: kind_of(Numeric)).and_return(['9000'].pack('H*'))
175
+ expect(described_class.apdu(ins: 0x20, p2: 0x80, data: '1234'.b, connection: connection, confirm: true)).to eq(''.b)
176
+ end
177
+
178
+ it 'selects an explicit application and sends legacy nonsecret compact APDUs in Ruby' do
179
+ expect(connection).to receive(:exchange).with(hash_including(data: ['00a4040007a0000005272101'].pack('H*'))).ordered.and_return(['9000'].pack('H*'))
180
+ expect(connection).to receive(:exchange).with(hash_including(data: ['00a1000000'].pack('H*'))).ordered.and_return(['720221619000'].pack('H*'))
181
+ result = described_class.command(args: %w[apdu --app oath a1=], connection: connection, confirm: true)
182
+ expect(result).to eq([{ data: ['72022161'].pack('H*'), status: 0x9000 }])
183
+ end
184
+
185
+ it 'supports explicit full APDU frames with status records and no automatic retries' do
186
+ expect(connection).to receive(:exchange).with(hash_including(data: ['00a1000000'].pack('H*'))).once.and_return(['6982'].pack('H*'))
187
+ expect(described_class.apdu(args: %w[--send-apdu 00a1000000], connection: connection, confirm: true)).to eq([{ data: ''.b, status: 0x6982 }])
188
+ end
189
+
190
+ it 'checks expected status words without including command payloads in errors' do
191
+ expect(connection).to receive(:exchange).once.and_return(['6982'].pack('H*'))
192
+ expect do
193
+ described_class.apdu(args: ['20:31323334='], connection: connection, confirm: true)
194
+ end.to raise_error(described_class::Protocol::APDUError) { |error|
195
+ expect(error.status).to eq(0x6982)
196
+ expect(error.full_message).not_to include('31323334')
197
+ }
198
+ end
199
+
200
+ it 'rejects consent bypass, malformed frames and mixed inputs before transport acquisition' do
201
+ expect(described_class::Transport).not_to receive(:open)
202
+ expect { described_class.apdu(ins: 0xa1) }.to raise_error(described_class::ConfirmationRequired)
203
+ [%w[--send-apdu xyz], %w[--app oath --send-apdu 00a1000000], %w[--send-apdu 00a1], ['invalid']].each do |args|
204
+ expect { described_class.apdu(args: args, confirm: true) }.to raise_error(ArgumentError)
205
+ end
206
+ expect { described_class.apdu(ins: 256, confirm: true) }.to raise_error(ArgumentError)
207
+ expect(described_class.apdu).to include(operations: ['apdu'])
208
+ end
209
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pty'
5
+ require 'io/console'
6
+ require 'io/wait'
7
+ require 'timeout'
8
+ require 'fcntl'
9
+
10
+ RSpec.describe PWN::Plugins::YubiKey, 'private native operator input' do
11
+ let(:connection) { double('caller native connection', exchange: nil) }
12
+
13
+ it 'reads a masked credential directly from the console and returns no secrets or device results' do
14
+ master, terminal = PTY.open
15
+ flags = terminal.fcntl(Fcntl::F_GETFL)
16
+ screen = ''.b
17
+ captured = nil
18
+ opts = { args: %w[oath accounts list], confirm: true, operator: true, terminal: terminal, connection: connection, timeout: 5 }.freeze
19
+ expect(described_class::OATH).to receive(:execute) do |received|
20
+ captured = received[:secret_provider].call(:password)
21
+ expect(captured).to eq('synthetic-password')
22
+ 'sensitive-device-output'
23
+ end
24
+ operator = Thread.new do
25
+ Timeout.timeout(5) do
26
+ screen << master.readpartial(1024) until screen.include?('YubiKey password: ')
27
+ expect(terminal.echo?).to be(false)
28
+ master.write("synthetic-password\n")
29
+ end
30
+ end
31
+ expect do
32
+ expect(described_class.interact(opts)).to eq(:completed)
33
+ end.not_to output.to_stdout
34
+ operator.value
35
+ screen << master.readpartial(1024) while master.wait_readable(0)
36
+ expect(screen).not_to include('synthetic-password', 'sensitive-device-output')
37
+ expect(captured).to eq("\0" * 'synthetic-password'.bytesize)
38
+ expect(terminal).not_to be_closed
39
+ expect(terminal.echo?).to be(true)
40
+ expect(terminal.fcntl(Fcntl::F_GETFL)).to eq(flags)
41
+ expect(opts).not_to have_key(:secret_provider)
42
+ ensure
43
+ operator&.kill&.join
44
+ master&.close
45
+ terminal&.close
46
+ end
47
+
48
+ it 'requires literal operator opt-in and refuses agent request contexts before looking up a console' do
49
+ expect(IO).not_to receive(:console)
50
+ expect(described_class::Transport).not_to receive(:open)
51
+ expect { described_class.interact(args: %w[oath accounts list]) }.to raise_error(described_class::ConfirmationRequired)
52
+ expect { described_class.interact(args: %w[oath accounts list], confirm: true) }.to raise_error(ArgumentError, /operator/)
53
+ prior = Thread.current[:pwn_request_runtime]
54
+ Thread.current[:pwn_request_runtime] = Object.new
55
+ expect do
56
+ described_class.interact(args: %w[oath accounts list], confirm: true, operator: true)
57
+ end.to raise_error(ArgumentError, /private.*operator|agent/)
58
+ ensure
59
+ Thread.current[:pwn_request_runtime] = prior
60
+ end
61
+
62
+ it 'rejects virtual terminals, credential argv and an overriding provider before device acquisition' do
63
+ expect(described_class::Transport).not_to receive(:open)
64
+ expect do
65
+ described_class.interact(args: %w[oath accounts list], confirm: true, operator: true, terminal: StringIO.new)
66
+ end.to raise_error(ArgumentError, /terminal/)
67
+ expect do
68
+ described_class.interact(args: %w[piv info --pin synthetic-secret], confirm: true, operator: true, terminal: StringIO.new)
69
+ end.to raise_error(ArgumentError)
70
+ expect do
71
+ described_class.interact(args: %w[oath accounts list], confirm: true, operator: true, secret_provider: ->(_name) { 'synthetic-secret' })
72
+ end.to raise_error(ArgumentError)
73
+ end
74
+
75
+ [false, true].each do |interrupt|
76
+ it "restores echo and descriptor flags on #{interrupt ? 'interruption' : 'input timeout'} without closing caller IO" do
77
+ master, terminal = PTY.open
78
+ flags = terminal.fcntl(Fcntl::F_GETFL) | Fcntl::O_NONBLOCK
79
+ terminal.fcntl(Fcntl::F_SETFL, flags)
80
+ expect(described_class::OATH).to receive(:execute) { |opts| opts[:secret_provider].call(:password) }
81
+ worker = Thread.new do
82
+ Thread.current.report_on_exception = false
83
+ described_class.interact(args: %w[oath accounts list], confirm: true, operator: true, terminal: terminal, connection: connection, timeout: interrupt ? 5 : 0.2)
84
+ end
85
+ screen = ''.b
86
+ Timeout.timeout(5) { screen << master.readpartial(1024) until screen.include?('YubiKey password: ') }
87
+ worker.raise(Interrupt) if interrupt
88
+ expect { Timeout.timeout(5) { worker.value } }.to raise_error(interrupt ? Interrupt : described_class::TimeoutError)
89
+ expect(terminal).not_to be_closed
90
+ expect(terminal.echo?).to be(true)
91
+ expect(terminal.fcntl(Fcntl::F_GETFL)).to eq(flags)
92
+ ensure
93
+ worker.kill.join if worker&.alive?
94
+ master&.close
95
+ terminal&.close
96
+ end
97
+ end
98
+ end
99
+
100
+ RSpec.describe PWN::Plugins::YubiKey, 'terminal access and descriptor ownership' do
101
+ %i[file wrapper].each do |kind|
102
+ %w[r w].each do |mode|
103
+ it "rejects #{kind} terminal mode #{mode} before consuming input or opening a device" do
104
+ master, slave = PTY.open
105
+ terminal = kind == :file ? File.open(slave.path, mode) : IO.for_fd(slave.fileno, mode, autoclose: false)
106
+ slave.echo = false
107
+ master.write("untouched-input\n")
108
+ expect(slave.wait_readable(1)).to eq(slave)
109
+ flags = terminal.fcntl(Fcntl::F_GETFL)
110
+ expect(flags & Fcntl::O_ACCMODE).to eq(Fcntl::O_RDWR) if kind == :wrapper
111
+ expect(described_class::Transport).not_to receive(:open)
112
+ expect(terminal).not_to receive(:getpass)
113
+ expect do
114
+ described_class.interact(args: %w[piv reset --force], confirm: true, operator: true, terminal: terminal)
115
+ end.to raise_error(ArgumentError, /readable and writable/)
116
+ expect(terminal).not_to be_closed
117
+ expect(terminal.fcntl(Fcntl::F_GETFL)).to eq(flags)
118
+ expect(slave.echo?).to be(false)
119
+ expect(Timeout.timeout(1) { slave.gets }).to eq("untouched-input\n")
120
+ expect(master.wait_readable(0)).to be_nil
121
+ ensure
122
+ terminal&.close
123
+ master&.close
124
+ slave&.close
125
+ end
126
+ end
127
+ end
128
+
129
+ it 'uses IO.console by default without consuming pending input for a read-only operation' do
130
+ master, terminal = PTY.open
131
+ terminal.echo = false
132
+ master.write("queued-fixture\n")
133
+ expect(terminal.wait_readable(1)).to eq(terminal)
134
+ flags = terminal.fcntl(Fcntl::F_GETFL)
135
+ connection = double('native OTP connection', descriptor: { protocol: :otp })
136
+ expect(connection).to receive(:exchange).with(hash_including(command: 0)).and_return(['050701030200'].pack('H*'))
137
+ expect(connection).not_to receive(:close)
138
+ expect(IO).to receive(:console).and_return(terminal)
139
+ expect(described_class.interact(args: %w[otp info], confirm: true, operator: true, connection: connection)).to eq(:completed)
140
+ expect(terminal.gets).to eq("queued-fixture\n")
141
+ expect(terminal.echo?).to be(false)
142
+ expect(terminal.fcntl(Fcntl::F_GETFL)).to eq(flags)
143
+ ensure
144
+ master&.close
145
+ terminal&.close
146
+ end
147
+ end
@@ -40,22 +40,25 @@ RSpec.describe 'Persistence redaction' do
40
40
  it 'sanitizes regular, learning, debug, mirror, stderr and fallback writes' do
41
41
  Dir.mktmpdir do |dir|
42
42
  secret = 'ASIAABCDEFGHIJKLMNOP'
43
- path = File.join(dir, 'log')
44
- allow(File).to receive(:open).and_call_original
45
- allow(File).to(receive(:open).with('/tmp/pwn.log', 'a').and_wrap_original { |m, _, mode| m.call(path, mode) })
43
+ allow(Dir).to receive(:home).and_return(dir)
44
+ path = File.join(dir, '.pwn', 'logs', 'pwn.log')
46
45
  PWN::Plugins::Log.append(level: :info, msg: secret, which_self: secret)
47
46
  PWN::Plugins::Log.append(level: :info, msg: { api_key: 'nested-fixture-only' })
48
47
  expect(File.read(path)).not_to include(secret, 'nested-fixture-only')
49
48
  expect(File.read(path)).to include('[REDACTED:aws:')
50
- PWN::Plugins::Log.start_debug(path: path)
51
- PWN::Plugins::Log.progress(msg: secret)
52
- PWN::Plugins::Log.mirror_tui!(msg: secret)
53
- PWN::Plugins::Log.capture_stderr!(text: "#{secret}\n")
49
+ debug_path = File.join(dir, '.pwn', 'logs', 'debug.log')
50
+ PWN::Plugins::Log.start_debug(path: debug_path)
51
+ PWN::Plugins::Log.progress(msg: "progress-sink #{secret}")
52
+ PWN::Plugins::Log.mirror_tui!(msg: "mirror-sink #{secret}")
53
+ PWN::Plugins::Log.capture_stderr!(text: "stderr-sink #{secret}\n")
54
54
  allow(PWN::Plugins::Log).to receive(:progress).and_return(false)
55
- PWN::Plugins::Log.note_interrupt!(where: secret)
55
+ PWN::Plugins::Log.note_interrupt!(where: "fallback-sink #{secret}")
56
56
  PWN::Plugins::Log.stop_debug
57
- expect(File.read(path)).not_to include(secret)
58
- expect(File.read(path)).to include('[REDACTED:aws:')
57
+ body = File.read(debug_path)
58
+ expect(body).not_to include(secret)
59
+ %w[progress-sink mirror-sink stderr-sink fallback-sink].each do |sink|
60
+ expect(body).to include("#{sink} [REDACTED:aws:")
61
+ end
59
62
  ensure
60
63
  PWN::Plugins::Log.stop_debug
61
64
  end