pwn 0.5.784 → 0.5.787

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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  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/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Independent wire fixtures: Yubico OATH protocol SELECT/LIST/CALCULATE,
6
+ # RFC 6238 SHA1 time 59 truncation (0x41397eea => 94287082).
7
+ describe 'PWN::Plugins::YubiKey::OATH native protocol' do
8
+ let(:oath) { PWN::Plugins::YubiKey::OATH }
9
+ let(:wire_class) do
10
+ Class.new do
11
+ attr_reader :seen
12
+
13
+ def initialize(steps)
14
+ @steps = steps.dup
15
+ @seen = []
16
+ end
17
+
18
+ def descriptor
19
+ { protocol: :apdu }
20
+ end
21
+
22
+ def exchange(opts = {})
23
+ raise 'wrong transport' unless opts[:protocol] == :apdu
24
+
25
+ actual = opts[:data].unpack1('H*')
26
+ @seen << actual
27
+ expected, response = @steps.shift
28
+ raise "unexpected APDU #{actual}" unless expected && (expected.is_a?(Regexp) ? expected.match?(actual) : actual == expected)
29
+
30
+ response.respond_to?(:call) ? response.call(opts[:data]) : [response].pack('H*')
31
+ end
32
+
33
+ def complete?
34
+ @steps.empty?
35
+ end
36
+ end
37
+ end
38
+ let(:select) { %w[00a4040007a0000005272101 7903050800710831323334353637389000] }
39
+
40
+ before do
41
+ require 'pwn/plugins/yubikey/protocol'
42
+ require 'pwn/plugins/yubikey/oath'
43
+ end
44
+
45
+ it 'calculates only the unique exact account with RFC 6238 leading digits' do
46
+ wire = wire_class.new([
47
+ select,
48
+ %w[00a1000000 7205216163637472062161636374329000],
49
+ %w[00a200011071046163637474080000000000000001 76050841397eea9000]
50
+ ])
51
+ expect(oath.code(connection: wire, account: 'acct', timestamp: 59, confirm: true)).to eq('94287082')
52
+ expect(wire.complete?).to be(true)
53
+ end
54
+
55
+ it 'performs mutual authentication exactly once before listing a locked application' do
56
+ allow(SecureRandom).to receive(:random_bytes).with(8).and_return('ABCDEFGH')
57
+ wire = wire_class.new([
58
+ [select[0], '790305080071083132333435363738740861626364656667689000'],
59
+ %w[00a30000207514db45e133f47e3e7a338c965f23bb02012cc4330a74084142434445464748 751496aa98f40d9ee3dc4b3c243ebd3dc8e53fcf4ef59000],
60
+ %w[00a1000000 720521616363749000]
61
+ ])
62
+ expect(oath.execute(connection: wire, args: %w[accounts list], password: 'synthetic oath', confirm: true)).to include(include(id: 'acct', period: 30))
63
+ expect(wire.complete?).to be(true)
64
+ end
65
+
66
+ it 'requires explicit consent before any code operation or secret-provider invocation' do
67
+ wire = wire_class.new([])
68
+ provider = ->(_name) { raise 'must not ask for a secret' }
69
+ expect { oath.code(connection: wire, account: 'acct', secret_provider: provider) }.to raise_error(PWN::Plugins::YubiKey::ConfirmationRequired)
70
+ expect(wire.seen).to be_empty
71
+ end
72
+
73
+ it 'returns leading zeroes for HOTP and uses the empty challenge' do
74
+ wire = wire_class.new([select, %w[00a1000000 720511616363749000], %w[00a20001087104616363747400 760506000000019000]])
75
+ expect(oath.code(connection: wire, account: 'acct', confirm: true)).to eq('000001')
76
+ expect(wire.complete?).to be(true)
77
+ end
78
+
79
+ it 'does not calculate a substring or Unicode-normalized account identity' do
80
+ ['acc', "a\u0301", ' acct'].each do |account|
81
+ wire = wire_class.new([select, %w[00a1000000 720521616363749000]])
82
+ expect { oath.code(connection: wire, account: account, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /exact/)
83
+ expect(wire.complete?).to be(true)
84
+ end
85
+ end
86
+
87
+ it 'does not retry or delete on a device memory error' do
88
+ wire = wire_class.new([select, %w[00a1000000 6581]])
89
+ expect { oath.code(connection: wire, account: 'acct', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::Error)
90
+ expect(wire.complete?).to be(true)
91
+ end
92
+
93
+ def apdu(ins, payload = ''.b, param1: 0, param2: 0)
94
+ ([0, ins, param1, param2] + (payload.empty? ? [] : [payload.bytesize])).pack('C*').unpack1('H*') + payload.unpack1('H*')
95
+ end
96
+
97
+ def field(tag, value)
98
+ [tag, value.bytesize].pack('CC') + value.b
99
+ end
100
+
101
+ it 'imports a raw secret with explicit overwrite consent and reads back the exact ID' do
102
+ payload = field(0x71, 'acct') + field(0x73, "\x21\x0612345678901234567890")
103
+ wire = wire_class.new([select, %w[00a1000000 9000], [apdu(1, payload), '9000'], %w[00a1000000 720521616363749000]])
104
+ result = oath.execute(connection: wire, args: %w[accounts add acct], secret: '12345678901234567890', confirm: true)
105
+ expect(result).to include(id: 'acct', verified: true)
106
+ expect(wire.complete?).to be(true)
107
+ end
108
+
109
+ it 'validates URI metadata without repairing conflicting issuers or malformed secrets' do
110
+ parsed = oath.parse_uri(uri: 'otpauth://totp/Example%3Aalice?secret=GEZDGNBVGY3TQOJQ&issuer=Example&algorithm=SHA256&digits=8&period=60')
111
+ expect(parsed).to include(name: 'alice', issuer: 'Example', secret: '1234567890', algorithm: :sha256, digits: 8, period: 60)
112
+ expect { oath.parse_uri(uri: 'otpauth://totp/A:x?secret=MY&issuer=B') }.to raise_error(ArgumentError)
113
+ expect { oath.parse_uri(uri: 'otpauth://totp/x?secret=MZ') }.to raise_error(ArgumentError)
114
+ end
115
+
116
+ it 'refuses invalid public import arguments before authentication or prompting' do
117
+ wire = wire_class.new([])
118
+ expect { oath.execute(connection: wire, args: %w[accounts add acct --digits 9], secret_provider: ->(_key) { raise 'prompted' }, confirm: true) }.to raise_error(ArgumentError)
119
+ expect(wire.seen).to be_empty
120
+ end
121
+
122
+ it 'renames exact IDs without deleting either account and verifies the new name' do
123
+ payload = field(0x71, 'acct') + field(0x71, 'new')
124
+ wire = wire_class.new([select, %w[00a1000000 720521616363749000], [apdu(5, payload), '9000'], %w[00a1000000 7204216e65779000]])
125
+ expect(oath.execute(connection: wire, args: %w[accounts rename acct new], confirm: true)).to include(id: 'new', verified: true)
126
+ expect(wire.complete?).to be(true)
127
+ end
128
+
129
+ it 'deletes only the exact ID and verifies absence' do
130
+ wire = wire_class.new([select, %w[00a1000000 720521616363749000], %w[0002000006710461636374 9000], %w[00a1000000 9000]])
131
+ expect(oath.execute(connection: wire, args: %w[accounts delete acct], confirm: true)).to include(deleted: 'acct', verified: true)
132
+ expect(wire.complete?).to be(true)
133
+ end
134
+
135
+ it 'sets an access key with PBKDF2/HMAC and reads back password protection' do
136
+ allow(SecureRandom).to receive(:random_bytes).with(8).and_return('ABCDEFGH')
137
+ payload = field(0x73, ['21ed78608d6e1b6954df83a2044711b48f'].pack('H*')) + field(0x74, 'ABCDEFGH') + field(0x75, ['96aa98f40d9ee3dc4b3c243ebd3dc8e53fcf4ef5'].pack('H*'))
138
+ locked_select = [select[0], '790305080071083132333435363738740861626364656667689000']
139
+ wire = wire_class.new([select, [apdu(3, payload), '9000'], locked_select])
140
+ expect(oath.execute(connection: wire, args: %w[access change], new_password: 'synthetic oath', confirm: true)).to include(locked: true, verified: true)
141
+ expect(wire.complete?).to be(true)
142
+ end
143
+
144
+ it 'clears access protection only after mutual authentication' do
145
+ allow(SecureRandom).to receive(:random_bytes).with(8).and_return('ABCDEFGH')
146
+ locked_select = [select[0], '790305080071083132333435363738740861626364656667689000']
147
+ wire = wire_class.new([locked_select, %w[00a30000207514db45e133f47e3e7a338c965f23bb02012cc4330a74084142434445464748 751496aa98f40d9ee3dc4b3c243ebd3dc8e53fcf4ef59000], %w[00030000027300 9000], select])
148
+ expect(oath.execute(connection: wire, args: %w[access change --clear], password: 'synthetic oath', confirm: true)).to include(locked: false, verified: true)
149
+ expect(wire.complete?).to be(true)
150
+ end
151
+
152
+ it 'requires an explicit encrypted-cache handoff and verifies remember and forget effects' do
153
+ cache = Class.new do
154
+ def encrypted? = true
155
+ def store(device_id:, access_key:) = @record = [device_id, access_key]
156
+ def fetch(device_id:) = @record && @record[0] == device_id ? @record[1] : nil
157
+
158
+ def delete(device_id:)
159
+ @record = nil if @record && @record[0] == device_id
160
+ end
161
+ end.new
162
+ allow(SecureRandom).to receive(:random_bytes).with(8).and_return('ABCDEFGH')
163
+ locked_select = [select[0], '790305080071083132333435363738740861626364656667689000']
164
+ wire = wire_class.new([locked_select, %w[00a30000207514db45e133f47e3e7a338c965f23bb02012cc4330a74084142434445464748 751496aa98f40d9ee3dc4b3c243ebd3dc8e53fcf4ef59000]])
165
+ result = oath.execute(connection: wire, args: %w[access remember], password: 'synthetic oath', encrypted_cache: cache, confirm: true)
166
+ expect(result).to include(cache_handoff: true, verified: true)
167
+ wire = wire_class.new([select])
168
+ expect(oath.execute(connection: wire, args: %w[access forget], encrypted_cache: cache, confirm: true)).to include(forgotten: true, verified: true)
169
+ expect(wire.complete?).to be(true)
170
+ wire = wire_class.new([])
171
+ expect { oath.execute(connection: wire, args: %w[access remember], password: 'synthetic oath', confirm: true) }.to raise_error(ArgumentError, /encrypted_cache/)
172
+ expect(wire.seen).to be_empty
173
+ end
174
+
175
+ it 'resets with DEAD and verifies new salt plus empty inventory' do
176
+ wire = wire_class.new([select, %w[0004dead00 9000], [select[0], '7903050800710838373635343332319000'], %w[00a1000000 9000]])
177
+ expect(oath.execute(connection: wire, args: ['reset'], confirm: true)).to include(reset: true, verified: true)
178
+ expect(wire.complete?).to be(true)
179
+ end
180
+
181
+ it 'calculates all TOTP codes but leaves HOTP and touch records untriggered' do
182
+ response = field(0x71, 'acct') + field(0x76, ['0600000001'].pack('H*')) + field(0x71, 'hotp') + field(0x77, "\x06") + field(0x71, 'touch') + field(0x7c, "\x06")
183
+ wire = wire_class.new([select, [apdu(0xa4, field(0x74, "#{"\x00" * 7}\x01"), param2: 1), "#{response.unpack1('H*')}9000"]])
184
+ values = oath.calculate_all(connection: wire, timestamp: 59, confirm: true)
185
+ expect(values.map { |record| record[:code]&.fetch(:value) }).to eq(['000001', nil, nil])
186
+ expect(values.map { |record| record[:oath_type] }).to eq(%i[totp hotp totp])
187
+ expect(wire.complete?).to be(true)
188
+ end
189
+
190
+ it 'exposes raw HMAC calculation without treating it as a numeric code' do
191
+ payload = field(0x71, 'acct') + field(0x74, 'challenge')
192
+ wire = wire_class.new([select, %w[00a1000000 720521616363749000], [apdu(0xa2, payload), "751506#{'ab' * 20}9000"]])
193
+ expect(oath.calculate(connection: wire, account: 'acct', challenge: 'challenge', confirm: true)).to eq(['ab' * 20].pack('H*'))
194
+ expect(wire.complete?).to be(true)
195
+ end
196
+
197
+ it 'imports an in-memory PSKC container without skipping invalid records' do
198
+ xml = '<KeyContainer xmlns="urn:ietf:params:xml:ns:keyprov:pskc" Version="1.0"><KeyPackage><Key Id="acct" Algorithm="urn:ietf:params:xml:ns:keyprov:pskc:totp"><AlgorithmParameters><ResponseFormat Length="6" Encoding="DECIMAL"/></AlgorithmParameters><Data><Secret><PlainValue>MTIzNDU2Nzg5MDEyMzQ1Njc4OTA=</PlainValue></Secret></Data></Key></KeyPackage></KeyContainer>'
199
+ payload = field(0x71, 'acct') + field(0x73, "\x21\x0612345678901234567890")
200
+ wire = wire_class.new([select, %w[00a1000000 9000], [apdu(1, payload), '9000'], %w[00a1000000 720521616363749000]])
201
+ expect(oath.execute(connection: wire, args: %w[accounts import], pskc: xml, confirm: true)).to include(include(id: 'acct', verified: true))
202
+ expect(wire.complete?).to be(true)
203
+ wire = wire_class.new([])
204
+ expect { oath.execute(connection: wire, args: %w[accounts import], pskc: xml.sub('pskc:totp', 'pskc:unknown'), confirm: true) }.to raise_error(ArgumentError)
205
+ expect(wire.seen).to be_empty
206
+ end
207
+
208
+ it 'unwraps a PSKC AES key-wrap secret using the RFC 3394 published vector' do
209
+ xml = '<KeyContainer xmlns="urn:ietf:params:xml:ns:keyprov:pskc" xmlns:x="http://www.w3.org/2001/04/xmlenc#" Version="1.0"><KeyPackage><Key Id="acct" Algorithm="urn:ietf:params:xml:ns:keyprov:pskc:hotp"><Data><Secret><EncryptedValue><x:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#kw-aes128"/><x:CipherData><x:CipherValue>H6aLCoEStEeu80vY+1p7gp0+hiNx0s/l</x:CipherValue></x:CipherData></EncryptedValue></Secret><Counter><PlainValue>0</PlainValue></Counter></Data></Key></KeyPackage></KeyContainer>'
210
+ secret = ['00112233445566778899aabbccddeeff'].pack('H*')
211
+ payload = field(0x71, 'acct') + field(0x73, "\x11\x06".b + secret)
212
+ wire = wire_class.new([select, %w[00a1000000 9000], [apdu(1, payload), '9000'], %w[00a1000000 720511616363749000]])
213
+ key = ['000102030405060708090a0b0c0d0e0f'].pack('H*')
214
+ expect(oath.execute(connection: wire, args: %w[accounts import], pskc: xml, pskc_key: key, confirm: true)).to include(include(id: 'acct', verified: true))
215
+ expect(wire.complete?).to be(true)
216
+ end
217
+
218
+ it 'rejects malformed XML and an invalid later import record before any APDU' do
219
+ wire = wire_class.new([])
220
+ expect { oath.execute(connection: wire, args: %w[accounts import], pskc: '<!DOCTYPE a [<!ENTITY x SYSTEM "file:///none">]><a/>', confirm: true) }.to raise_error(ArgumentError)
221
+ expect { oath.execute(connection: wire, args: %w[accounts import], credentials: [{ name: 'one', secret: 'x' * 20 }, { name: 'two', secret: 'x' }], confirm: true) }.to raise_error(ArgumentError)
222
+ expect(wire.seen).to be_empty
223
+ end
224
+
225
+ it 'imports authenticated AES-CBC PSKC with PBKDF2 and rejects a bad MAC before I/O' do
226
+ # Independently encrypted with Python cryptography AES-CBC / hashlib PBKDF2.
227
+ xml = <<~XML
228
+ <KeyContainer xmlns="urn:ietf:params:xml:ns:keyprov:pskc" xmlns:x="http://www.w3.org/2001/04/xmlenc#" xmlns:k="http://www.w3.org/2009/xmlenc11#" Version="1.0">
229
+ <EncryptionKey><k:DerivedKey><k:KeyDerivationMethod Algorithm="http://www.w3.org/2009/xmlenc11#pbkdf2"><k:PBKDF2-params><k:Salt><k:Specified>MDEyMzQ1Njc4OWFiY2RlZg==</k:Specified></k:Salt><k:IterationCount>1000</k:IterationCount><k:KeyLength>16</k:KeyLength><k:PRF Algorithm="http://www.w3.org/2001/04/xmldsig-more#hmac-sha256"/></k:PBKDF2-params></k:KeyDerivationMethod></k:DerivedKey></EncryptionKey>
230
+ <MACMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#hmac-sha256"><MACKey><x:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/><x:CipherData><x:CipherValue>SkpKSkpKSkpKSkpKSkpKSmxrtkfn/Z3m3XbUoFWKDNZhAjYTOVdo4oaO5plAkray</x:CipherValue></x:CipherData></MACKey></MACMethod>
231
+ <KeyPackage><Key Id="acct" Algorithm="urn:ietf:params:xml:ns:keyprov:pskc:totp"><Data><Secret><EncryptedValue><x:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/><x:CipherData><x:CipherValue>SUlJSUlJSUlJSUlJSUlJSdb4gWWgYtJgOxUEkeaFZQt1DfO02SIT0Ze547y+hMFy</x:CipherValue></x:CipherData></EncryptedValue><ValueMAC>RO0CeBbbOXmD5PARLx3KS5LiCdVI9dgZgoUMW7FjX3A=</ValueMAC></Secret></Data></Key></KeyPackage>
232
+ </KeyContainer>
233
+ XML
234
+ payload = field(0x71, 'acct') + field(0x73, "\x21\x0612345678901234567890")
235
+ wire = wire_class.new([select, %w[00a1000000 9000], [apdu(1, payload), '9000'], %w[00a1000000 720521616363749000]])
236
+ expect(oath.execute(connection: wire, args: %w[accounts import], pskc: xml, pskc_password: 'synthetic PSKC', confirm: true)).to include(include(verified: true))
237
+ expect(wire.complete?).to be(true)
238
+ wire = wire_class.new([])
239
+ expect { oath.execute(connection: wire, args: %w[accounts import], pskc: xml.sub('RO0Ce', 'AO0Ce'), pskc_password: 'synthetic PSKC', confirm: true) }.to raise_error(ArgumentError, /integrity/)
240
+ expect(wire.seen).to be_empty
241
+ end
242
+
243
+ it 'uses OATH SEND REMAINING with zero Le across fragmented LIST responses' do
244
+ wire = wire_class.new([select, %w[00a1000000 72056104], %w[00a5000000 21616363749000]])
245
+ expect(oath.execute(connection: wire, args: %w[accounts list])).to include(include(id: 'acct'))
246
+ expect(wire.complete?).to be(true)
247
+ end
248
+
249
+ it 'matches independent RFC 6238 SHA1, SHA256 and SHA512 truncation vectors' do
250
+ [[1, 59, '41397eea', '94287082'], [1, 1_111_111_109, '3610f84c', '07081804'], [2, 59, '2c78e04e', '46119246'], [3, 59, '1d3f6530', '90693936']].each do |algorithm, time, truncated, value|
251
+ inventory = "#{field(0x72, "#{[0x20 | algorithm].pack('C')}acct").unpack1('H*')}9000"
252
+ payload = field(0x71, 'acct') + field(0x74, [time / 30].pack('Q>'))
253
+ wire = wire_class.new([select, ['00a1000000', inventory], [apdu(0xa2, payload, param2: 1), "760508#{truncated}9000"]])
254
+ expect(oath.code(connection: wire, account: 'acct', timestamp: time, confirm: true)).to eq(value)
255
+ expect(wire.complete?).to be(true)
256
+ end
257
+ end
258
+
259
+ it 'uses the nonstandard period encoded in the exact credential ID' do
260
+ payload = field(0x71, '60/acct') + field(0x74, [1].pack('Q>'))
261
+ wire = wire_class.new([select, ['00a1000000', "#{field(0x72, "\x2260/acct").unpack1('H*')}9000"], [apdu(0xa2, payload, param2: 1), '760506000000019000']])
262
+ expect(oath.code(connection: wire, account: '60/acct', timestamp: 119, confirm: true)).to eq('000001')
263
+ expect(wire.complete?).to be(true)
264
+ end
265
+
266
+ it 'imports HOTP touch/counter attributes using the property pair rather than a TLV' do
267
+ secret = 'x' * 20
268
+ payload = "#{field(0x71, 'acct')}#{field(0x73, "\x12\x07#{secret}")}\x78\x02#{field(0x7a, [7].pack('N'))}"
269
+ wire = wire_class.new([select, %w[00a1000000 9000], [apdu(1, payload), '9000'], %w[00a1000000 720512616363749000]])
270
+ expect(oath.execute(connection: wire, args: %w[accounts add acct --oath-type HOTP --algorithm SHA256 --digits 7 --counter 7 --touch], secret: secret, confirm: true)).to include(oath_type: :hotp, algorithm: :sha256)
271
+ expect(wire.complete?).to be(true)
272
+ end
273
+
274
+ it 'refuses overwrite, unsupported touch and unsupported rename without mutation' do
275
+ wire = wire_class.new([select, %w[00a1000000 720521616363749000]])
276
+ expect { oath.execute(connection: wire, args: %w[accounts add acct], secret: 'x' * 20, confirm: true) }.to raise_error(ArgumentError, /overwrite/)
277
+ old = [select[0], '7903040205710831323334353637389000']
278
+ wire = wire_class.new([old])
279
+ expect { oath.execute(connection: wire, args: %w[accounts add acct --touch], secret: 'x' * 20, confirm: true) }.to raise_error(PWN::Plugins::YubiKey::Error, /unsupported/)
280
+ wire = wire_class.new([old])
281
+ expect { oath.execute(connection: wire, args: %w[accounts rename acct new], confirm: true) }.to raise_error(PWN::Plugins::YubiKey::Error, /unsupported/)
282
+ expect(wire.complete?).to be(true)
283
+ end
284
+
285
+ it 'fails mutual authentication without invoking list, fallback credentials or repair' do
286
+ allow(SecureRandom).to receive(:random_bytes).with(8).and_return('ABCDEFGH')
287
+ wire = wire_class.new([[select[0], '790305080071083132333435363738740861626364656667689000'], ['00a30000207514db45e133f47e3e7a338c965f23bb02012cc4330a74084142434445464748', "7514#{'00' * 20}9000"]])
288
+ expect { oath.code(connection: wire, account: 'acct', password: 'synthetic oath', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /authentication/)
289
+ expect(wire.complete?).to be(true)
290
+ end
291
+
292
+ it 'rejects duplicate inventory identities and malformed code responses' do
293
+ wire = wire_class.new([select, %w[00a1000000 72052161636374720521616363749000]])
294
+ expect { oath.code(connection: wire, account: 'acct', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /Duplicate/)
295
+ ["\x06\x01", "\x09\x00\x00\x00\x01"].each do |truncated|
296
+ wire = wire_class.new([select, %w[00a1000000 720511616363749000], ['00a20001087104616363747400', "#{field(0x76, truncated).unpack1('H*')}9000"]])
297
+ expect { oath.code(connection: wire, account: 'acct', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::ResponseError, /Malformed/)
298
+ expect(wire.complete?).to be(true)
299
+ end
300
+ end
301
+
302
+ it 'preserves URI touch policy and rejects nonboolean controls before I/O' do
303
+ payload = "#{field(0x71, 'acct')}#{field(0x73, "\x21\x06#{'1234567890'.ljust(14, "\x00")}")}\x78\x02"
304
+ wire = wire_class.new([select, %w[00a1000000 9000], [apdu(1, payload), '9000'], %w[00a1000000 720521616363749000]])
305
+ expect(oath.execute(connection: wire, args: %w[accounts uri --touch], uri: 'otpauth://totp/acct?secret=GEZDGNBVGY3TQOJQ', confirm: true)).to include(verified: true)
306
+ expect(wire.complete?).to be(true)
307
+ wire = wire_class.new([])
308
+ expect { oath.execute(connection: wire, args: %w[accounts add acct], generate: 'false', confirm: true) }.to raise_error(ArgumentError, /boolean/)
309
+ expect(wire.seen).to be_empty
310
+ end
311
+
312
+ it 'supports all-account command routing without incrementing HOTP counters' do
313
+ response = field(0x71, 'hotp') + field(0x77, "\x06")
314
+ wire = wire_class.new([select, [apdu(0xa4, field(0x74, [1].pack('Q>')), param2: 1), "#{response.unpack1('H*')}9000"]])
315
+ expect(oath.execute(connection: wire, args: %w[accounts code], timestamp: 59, confirm: true)).to include(include(oath_type: :hotp, code: nil))
316
+ expect(wire.complete?).to be(true)
317
+ end
318
+
319
+ it 'does not treat nonboolean direct cache controls as consent to a cache handoff' do
320
+ wire = wire_class.new([])
321
+ expect { oath.code(connection: wire, account: 'acct', confirm: true, remember: 'false') }.to raise_error(ArgumentError, /boolean/)
322
+ expect(wire.seen).to be_empty
323
+ end
324
+
325
+ it 'reads OATH version and protection state without trying an access password' do
326
+ wire = wire_class.new([select])
327
+ expect(oath.execute(connection: wire, args: ['info'])).to include(version: [5, 8, 0], locked: false)
328
+ expect(wire.complete?).to be(true)
329
+ end
330
+
331
+ it 'propagates a calculation timeout without retry or hidden mutation' do
332
+ failure = ->(_packet) { raise PWN::Plugins::YubiKey::TimeoutError, 'synthetic cancelled I/O' }
333
+ wire = wire_class.new([select, %w[00a1000000 720511616363749000], ['00a20001087104616363747400', failure]])
334
+ expect { oath.code(connection: wire, account: 'acct', confirm: true) }.to raise_error(PWN::Plugins::YubiKey::TimeoutError)
335
+ expect(wire.complete?).to be(true)
336
+ expect(wire.seen.length).to eq(3)
337
+ end
338
+
339
+ it 'documents every public API and secret option in actual help output' do
340
+ expect { oath.help }.to output(/\.execute\(.*secret_base32:.*encrypted_cache:.*\.operations.*\.parse_uri.*\.code\(.*account:.*\.calculate\(.*challenge:.*\.calculate_all\(.*\.authors/m).to_stdout
341
+ end
342
+
343
+ it 'keeps help local and does not claim groups as implemented leaves' do
344
+ wire = wire_class.new([])
345
+ expect(oath.execute(connection: wire, args: ['accounts'])).to include(application: :oath)
346
+ expect(oath.operations).not_to include('accounts', 'access')
347
+ expect(wire.seen).to be_empty
348
+ end
349
+ end