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,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'openssl'
5
+ require 'pwn/plugins/yubikey/otp'
6
+
7
+ RSpec.describe 'PWN::Plugins::YubiKey::OTP native protocol' do
8
+ subject(:otp) { PWN::Plugins::YubiKey::OTP }
9
+
10
+ let(:wire) do
11
+ Class.new do
12
+ attr_reader :calls, :descriptor
13
+
14
+ def initialize(responses, descriptor = { protocol: :otp })
15
+ @responses = responses
16
+ @descriptor = descriptor
17
+ @calls = []
18
+ end
19
+
20
+ def exchange(opts = {})
21
+ @calls << opts
22
+ raise 'Unexpected device request' if @responses.empty?
23
+
24
+ response = @responses.shift
25
+ raise response if response.is_a?(Exception)
26
+
27
+ response.respond_to?(:call) ? response.call(opts) : response
28
+ end
29
+ end
30
+ end
31
+
32
+ let(:status) { [5, 7, 1, 9, 3, 0].pack('C*') }
33
+
34
+ it 'returns RFC 2202 raw SHA1 bytes over OTP HID, not hex or TOTP' do
35
+ # RFC 2202 case 1; the peer boundary, not the host application, owns the key.
36
+ expected = ['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*')
37
+ peer = wire.new([status, expected])
38
+ result = otp.challenge_response(connection: peer, challenge: 'Hi There'.b, slot: 2, confirm: true)
39
+ expect(result).to eq(expected)
40
+ expect(result.bytesize).to eq(20)
41
+ expect(peer.calls.last).to include(protocol: :otp, command: 0x38, data: 'Hi There'.b.ljust(64, "\0"), response_length: 20)
42
+ end
43
+
44
+ it 'preserves trailing zero challenge bytes with nonzero padding' do
45
+ peer = wire.new([status, 'x' * 20])
46
+ otp.challenge_response(connection: peer, challenge: "a\0".b, slot: 1, confirm: true)
47
+ expect(peer.calls.last[:data]).to eq("a\0".b + ("\1" * 62))
48
+ end
49
+
50
+ it 'validates raw challenge length and literal consent before device traffic' do
51
+ peer = wire.new([])
52
+ [nil, '', 'x' * 65, 12].each do |invalid|
53
+ expect { otp.challenge_response(connection: peer, challenge: invalid, confirm: true) }.to raise_error(ArgumentError)
54
+ end
55
+ expect { otp.challenge_response(connection: peer, challenge: 'a', confirm: 'true') }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
56
+ expect(peer.calls).to be_empty
57
+ end
58
+
59
+ it 'programs an independent 52-byte configuration vector and verifies real HMAC readback' do
60
+ # X25 complement from an independent MSB-first 0x1021 calculation, not OTP.crc.
61
+ config = ['000000000000000000000000000000000b0b0b0b00000b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0000000000000024402600007818'].pack('H*')
62
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
63
+ expected = ['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*')
64
+ peer = wire.new([status, after, after, after, expected])
65
+ result = otp.provision_challenge_response(connection: peer, slot: 2, key: "\x0b".b * 20, touch: false, confirm: true, verification_challenge: 'Hi There'.b)
66
+ expect(peer.calls[1]).to include(command: 3, data: config + ("\0" * 6))
67
+ expect(result).to eq(:verified)
68
+ expect(peer.calls.count { |call| call[:command] == 0 }).to eq(3)
69
+ end
70
+
71
+ it 'does not retry programming when explicit readback is stale or HMAC mismatches' do
72
+ peer = wire.new([status, status, status])
73
+ expect { otp.provision_challenge_response(connection: peer, key: 'k' * 20, touch: false, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /uncertain/i)
74
+ expect(peer.calls.count { |call| call[:command] == 3 }).to eq(1)
75
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
76
+ peer = wire.new([status, after, after, after, 'x' * 20])
77
+ expect { otp.provision_challenge_response(connection: peer, key: 'k' * 20, touch: false, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /uncertain/i)
78
+ expect(peer.calls.count { |call| call[:command] == 3 }).to eq(1)
79
+ end
80
+
81
+ it 'routes info locally and distinguishes empty-slot deletion readback from ACK' do
82
+ expect(otp.operations).to contain_exactly('info', 'swap', 'ndef', 'delete', 'yubiotp', 'static', 'chalresp', 'calculate', 'hotp', 'settings')
83
+ expect(otp.execute(args: [])).to include(application: :otp)
84
+ peer = wire.new([status])
85
+ expect(otp.execute(args: ['info'], connection: peer)[:slots][1][:configured]).to be(true)
86
+ empty = [5, 7, 1, 0, 0, 0].pack('C*')
87
+ peer = wire.new([status, empty, empty])
88
+ result = otp.execute(args: ['delete', '2', '--force'], connection: peer, confirm: true)
89
+ expect(peer.calls[1]).to include(command: 3, data: "\0" * 58)
90
+ expect(result[:status][:slots][2][:configured]).to be(false)
91
+ end
92
+
93
+ it 'swaps configuration-state bits and verifies fresh status' do
94
+ before = [5, 7, 1, 9, 5, 0].pack('C*')
95
+ after = [5, 7, 1, 10, 10, 0].pack('C*')
96
+ peer = wire.new([before, after, after])
97
+ expect(otp.execute(args: ['swap'], connection: peer, confirm: true)[:verified]).to be(true)
98
+ expect(peer.calls[1]).to include(command: 6, data: "\0" * 6)
99
+ end
100
+
101
+ it 'updates keyboard flags and access-code without reprogramming secrets' do
102
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
103
+ peer = wire.new([status, after, after])
104
+ otp.execute(args: ['settings', '2', '--pacing', '60', '--no-enter', '--serial-usb-visible'], connection: peer, confirm: true, access_code: '123456', new_access_code: 'abcdef')
105
+ config = peer.calls[1][:data]
106
+ expect(peer.calls[1][:command]).to eq(5)
107
+ expect(config.byteslice(0, 38)).to eq("\0" * 38)
108
+ expect(config.byteslice(38, 6)).to eq('abcdef')
109
+ expect(config.byteslice(44, 6)).to eq([16, 0x36, 0, 12, 0, 0].pack('C*'))
110
+ expect(config.byteslice(52, 6)).to eq('123456')
111
+ end
112
+
113
+ it 'rejects credential argv and never treats force as consent' do
114
+ peer = wire.new([])
115
+ expect { otp.execute(args: %w[chalresp 2 secret], connection: peer, confirm: true) }.to raise_error(ArgumentError)
116
+ expect { otp.execute(args: ['delete', '2', '--force'], connection: peer) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
117
+ expect(peer.calls).to be_empty
118
+ end
119
+
120
+ it 'programs YubiOTP using binary keys without exposing or exporting secrets' do
121
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
122
+ peer = wire.new([status, after, after])
123
+ result = otp.execute(args: ['yubiotp', '1', '--public-id', 'cbdefg', '--no-enter'], connection: peer, confirm: true, key: 'k' * 16, private_id: 'u' * 6)
124
+ config = peer.calls[1][:data]
125
+ expect(config.byteslice(0, 16)).to eq([0x01, 0x23, 0x45].pack('C*').ljust(16, "\0"))
126
+ expect(config.byteslice(16, 22)).to eq(('u' * 6) + ('k' * 16))
127
+ expect(config.byteslice(44, 6)).to eq([3, 0x34, 0, 0, 0, 0].pack('C*'))
128
+ expect(result[:secret_readback]).to be(false)
129
+ expect(result.to_s).not_to include('kkkkkkkkkkkkkkkk')
130
+ end
131
+
132
+ it 'programs static keyboard scan codes rather than UTF8 password bytes' do
133
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
134
+ peer = wire.new([status, after, after])
135
+ otp.execute(args: ['static', '2', '--keyboard-layout', 'US'], connection: peer, confirm: true, password: 'Ab1!')
136
+ config = peer.calls[1][:data]
137
+ expect(config.byteslice(0, 38)).to eq([0x84, 5, 0x1e, 0x9e].pack('C*').ljust(38, "\0"))
138
+ expect(config.byteslice(44, 4)).to eq([16, 0x34, 0x20, 2].pack('C*'))
139
+ end
140
+
141
+ it 'programs HOTP counter and identifier with exact binary field placement' do
142
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
143
+ peer = wire.new([status, after, after])
144
+ otp.execute(args: ['hotp', '2', '--digits', '8', '--counter', '256', '--identifier', 'ubhe12345678'], connection: peer, confirm: true, key: '12345678901234567890')
145
+ config = peer.calls[1][:data]
146
+ expect(config.byteslice(0, 6)).to eq(['e16312345678'].pack('H*'))
147
+ expect(config.byteslice(16, 6)).to eq("7890#{[16].pack('n')}")
148
+ expect(config.byteslice(44, 4)).to eq([6, 0x34, 0x60, 0x42].pack('C*'))
149
+ end
150
+
151
+ it 'encodes URI and text NDEF records and performs separate status readback' do
152
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
153
+ peer = wire.new([status, after, after], protocol: :otp, nfc: true)
154
+ otp.execute(args: ['ndef', '2', '--prefix', 'https://example.test/'], connection: peer, confirm: true)
155
+ expect(peer.calls[1]).to include(command: 9, data: "#{[14, 0x55, 4].pack('C*')}example.test/#{"\0" * 40}#{"\0" * 6}")
156
+ peer = wire.new([status, after, after], protocol: :otp, nfc: true)
157
+ otp.execute(args: ['ndef', '1', '--ndef-type', 'TEXT', '--prefix', 'hello'], connection: peer, confirm: true)
158
+ expect(peer.calls[1][:data].byteslice(0, 10)).to eq("#{[8, 0x54, 2].pack('C*')}enhello")
159
+ end
160
+
161
+ it 'calculates RFC 6238 TOTP natively while preserving raw HMAC API' do
162
+ response = OpenSSL::HMAC.digest('SHA1', '12345678901234567890', [1].pack('Q>'))
163
+ peer = wire.new([status, response])
164
+ expect(otp.execute(args: ['calculate', '2', '59', '--totp', '--digits', '8'], connection: peer, confirm: true)).to eq('94287082')
165
+ expect(peer.calls.last[:data]).to eq([1].pack('Q>').ljust(64, "\0"))
166
+ end
167
+
168
+ it 'uses the OTP smartcard application on NFC with full R-APDU validation' do
169
+ expected = ['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*')
170
+ peer = wire.new([status + "\x90\0".b, expected + "\x90\0".b], protocol: :apdu, transport: :nfc)
171
+ expect(otp.challenge_response(connection: peer, challenge: 'Hi There', confirm: true)).to eq(expected)
172
+ expect(peer.calls.first[:data]).to start_with(['00a4040007a0000005272001'].pack('H*'))
173
+ expect(peer.calls.last[:data]).to eq(['0001380040'].pack('H*') + 'Hi There'.ljust(64, "\0"))
174
+ peer = wire.new([status + "\x90\0".b, "\x69\x85".b], protocol: :apdu, transport: :nfc)
175
+ expect { otp.challenge_response(connection: peer, challenge: 'Hi There', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
176
+ expect(peer.calls.length).to eq(2)
177
+ end
178
+
179
+ it 'encodes non-US static layouts and rejects unavailable characters' do
180
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
181
+ { 'DE' => ['z', 0x1c], 'FR' => ['a', 0x14], 'UK' => ['£', 0xa0], 'IT' => ['a', 4], 'BEPO' => ['b', 0x14], 'NORMAN' => ['a', 4] }.each do |layout, (password, code)|
182
+ peer = wire.new([status, after, after])
183
+ otp.provision_static(connection: peer, slot: 2, password: password, keyboard_layout: layout, confirm: true)
184
+ expect(peer.calls[1][:data].getbyte(0)).to eq(code)
185
+ end
186
+ end
187
+
188
+ it 'generates challenge keys only with consent and an explicit in-memory sink' do
189
+ key = 'k' * 20
190
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
191
+ allow(SecureRandom).to receive(:random_bytes).with(20).and_return(key)
192
+ captured = {}
193
+ peer = wire.new([status, after, after, after, OpenSSL::HMAC.digest('SHA1', key, 'verify')])
194
+ result = otp.execute(args: ['chalresp', '2', '--generate'], connection: peer, confirm: true, verification_challenge: 'verify', secret_sink: ->(name, secret) { captured[name] = secret })
195
+ expect(captured).to eq(key: key)
196
+ expect(result[:verification]).to eq(:hmac_sha1)
197
+ expect(peer.calls[1][:data].getbyte(47)).to eq(0x26)
198
+ end
199
+
200
+ it 'exports explicitly requested Yubico CSV to a private file after verified programming' do
201
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
202
+ Dir.mktmpdir('otp-native-export-') do |dir|
203
+ path = File.join(dir, 'configuration.csv')
204
+ peer = wire.new([status, [12_345_678].pack('N'), status, after, after])
205
+ otp.execute(args: ['yubiotp', '1', '--public-id', 'cb', '--config-output', path], connection: peer, confirm: true, key: 'k' * 16, private_id: 'u' * 6)
206
+ fields = File.read(path).strip.split(',', -1)
207
+ expect(fields.take(5)).to eq(['12345678', 'cb', ('75' * 6), ('6b' * 16), ''])
208
+ expect(File.stat(path).mode & 0o777).to eq(0o600)
209
+ expect(fields.last).to eq('')
210
+ end
211
+ end
212
+
213
+ it 'accepts explicitly encoded Base32 TOTP secrets but never guesses an encoding' do
214
+ key = '12345678901234567890'
215
+ after = [5, 7, 1, 10, 3, 0].pack('C*')
216
+ peer = wire.new([status, after, after, after, OpenSSL::HMAC.digest('SHA1', key, 'verify')])
217
+ result = otp.execute(args: ['chalresp', '2', '--totp'], connection: peer, confirm: true, key: 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', verification_challenge: 'verify')
218
+ expect(result[:verification]).to eq(:hmac_sha1)
219
+ expect(peer.calls[1][:data].byteslice(22, 16)).to eq('1234567890123456')
220
+ end
221
+
222
+ it 'refuses known USB-smartcard HMAC provisioning before overwriting a slot' do
223
+ peer = wire.new([], protocol: :apdu, transport: :usb)
224
+ expect { otp.provision_challenge_response(connection: peer, key: 'k' * 20, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::CommandError, /HID/)
225
+ expect(peer.calls).to be_empty
226
+ end
227
+
228
+ it 'validates unsupported input and export destinations before any configuration write' do
229
+ peer = wire.new([])
230
+ expect { otp.provision_hotp(connection: peer, slot: 2, key: 'k' * 20, counter: 1, confirm: true) }.to raise_error(ArgumentError)
231
+ expect { otp.provision_static(connection: peer, slot: 2, password: '!', confirm: true) }.to raise_error(ArgumentError)
232
+ expect { otp.configure_ndef(connection: peer, slot: 2, prefix: 'x' * 60, confirm: true) }.to raise_error(ArgumentError)
233
+ Dir.mktmpdir('otp-export-links-') do |dir|
234
+ target = File.join(dir, 'private.csv')
235
+ File.write(target, 'unchanged', mode: 'w', perm: 0o600)
236
+ link = File.join(dir, 'link.csv')
237
+ File.symlink(target, link)
238
+ expect { otp.provision_yubiotp(connection: peer, slot: 2, public_id: 'cb', key: 'k' * 16, private_id: 'u' * 6, config_output: link, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
239
+ expect(File.read(target)).to eq('unchanged')
240
+ end
241
+ expect(peer.calls).to be_empty
242
+ end
243
+
244
+ it 'checks NFC capability metadata before programming NDEF on an unknown HID model' do
245
+ peer = wire.new([status, [9, 1, 2, 0, 1, 5, 3, 5, 7, 1].pack('C*')])
246
+ expect { otp.configure_ndef(connection: peer, slot: 2, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::CommandError, /NFC/)
247
+ expect(peer.calls.none? { |call| [8, 9].include?(call[:command]) }).to be(true)
248
+ end
249
+
250
+ it 'rejects ambiguous boolean controls and missing credentials before authentication' do
251
+ peer = wire.new([])
252
+ provider = instance_double(Proc)
253
+ expect(provider).not_to receive(:call)
254
+ expect { otp.provision_static(connection: peer, slot: 2, generate: 'false', secret_provider: provider, confirm: true) }.to raise_error(ArgumentError)
255
+ expect { otp.provision_challenge_response(connection: peer, secret_provider: provider, confirm: false) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
256
+ expect(peer.calls).to be_empty
257
+ end
258
+
259
+ it 'propagates cancelled HMAC operations without provisioning or replay' do
260
+ peer = wire.new([status, PWN::Plugins::YubiKey::TimeoutError.new('cancelled synthetic touch wait')])
261
+ expect { otp.challenge_response(connection: peer, challenge: 'a', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
262
+ expect(peer.calls.map { |call| call[:command] }).to eq([0, 0x38])
263
+ end
264
+
265
+ it 'exercises the real native HID codec with an independent RFC response and wire CRC' do
266
+ require 'pwn/plugins/yubikey/transport'
267
+ feature = "\0".b + status + "\0".b
268
+ digest = ['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*')
269
+ # Independent reflected X25 complement for this RFC digest is little-endian 5754.
270
+ body = (digest + ['5754'].pack('H*')).ljust(28, "\0")
271
+ reports = Array.new(5, feature) + 4.times.map { |index| body.byteslice(index * 7, 7) + [0x40 + index].pack('C') } + ["\0\0\0\0\0\0\0\x40".b]
272
+ io = Class.new do
273
+ attr_reader :written, :closed
274
+
275
+ def initialize(reports)
276
+ @reports = reports
277
+ @written = []
278
+ end
279
+
280
+ def read_feature
281
+ @reports.shift || raise('Unexpected HID report read')
282
+ end
283
+
284
+ def write_feature(value)
285
+ @written << value
286
+ end
287
+
288
+ def close
289
+ @closed = true
290
+ end
291
+ end.new(reports)
292
+ connection = PWN::Plugins::YubiKey::Transport::Connection.new(descriptor: { protocol: :otp }, io: io, timeout: 1)
293
+ expect(otp.challenge_response(connection: connection, challenge: 'Hi There', slot: 2, confirm: true, timeout: 1)).to eq(digest)
294
+ expect(io.written.map { |packet| packet.getbyte(7) }).to eq([0x80, 0x81, 0x89, 0xff])
295
+ connection.close
296
+ expect(io.closed).to be(true)
297
+ ensure
298
+ connection&.close
299
+ end
300
+
301
+ it 'verifies NFC provisioning cryptographically on older firmware with the documented zero-sequence quirk' do
302
+ before = [5, 2, 0, 9, 3, 0].pack('C*')
303
+ after = [5, 2, 0, 0, 3, 0].pack('C*')
304
+ expected = ['b617318655057264e28bc0b6fb378c8ef146be00'].pack('H*')
305
+ peer = wire.new([before, after, after, after, expected].map { |data| data + "\x90\0".b }, protocol: :apdu, transport: :nfc)
306
+ result = otp.provision_challenge_response(connection: peer, slot: 2, key: "\x0b".b * 20, touch: false, confirm: true, verification_challenge: 'Hi There', details: true)
307
+ expect(result).to include(verified: true, verification: :hmac_sha1)
308
+ expect(peer.calls[1][:data]).to start_with(['000103003a'].pack('H*'))
309
+ expect(peer.calls.count { |call| call[:data].start_with?(['000103'].pack('H*')) }).to eq(1)
310
+ end
311
+
312
+ it 'closes a reserved export handle if validation fails before device programming' do
313
+ peer = wire.new([])
314
+ file = instance_double(File, closed?: false)
315
+ path = File.join(Dir.tmpdir, 'synthetic-otp-export.csv')
316
+ allow(File).to receive(:open).and_call_original
317
+ expect(File).to receive(:open).with(path, anything, 0o600).and_return(file)
318
+ expect(file).to receive(:stat).and_raise(IOError, 'synthetic fstat failure')
319
+ expect(file).to receive(:close).once
320
+ expect { otp.provision_yubiotp(connection: peer, slot: 2, public_id: 'cb', key: 'k' * 16, private_id: 'u' * 6, config_output: path, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
321
+ expect(peer.calls).to be_empty
322
+ end
323
+
324
+ it 'reads YubiKey 4 FIPS approval only after actual capability metadata identifies the edition' do
325
+ old_status = [4, 4, 1, 9, 3, 0].pack('C*')
326
+ fields = ['0102003f0401810503040401'].pack('H*')
327
+ peer = wire.new([old_status, old_status, [fields.bytesize].pack('C') + fields, "\1".b])
328
+ expect(otp.execute(args: ['info'], connection: peer)[:fips_approved]).to be(true)
329
+ expect(peer.calls.last).to include(command: 0x14, response_length: 1)
330
+ end
331
+
332
+ it 'rejects truncated responses and never retries a failed challenge' do
333
+ peer = wire.new([status, 'x' * 19])
334
+ expect { otp.challenge_response(connection: peer, challenge: 'a', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError)
335
+ expect(peer.calls.length).to eq(2)
336
+ end
337
+ end