extendvcc-cli 0.1.0__tar.gz → 0.1.1__tar.gz

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 (35) hide show
  1. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/.github/workflows/release.yml +7 -3
  2. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/CHANGELOG.md +11 -1
  3. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/PKG-INFO +1 -1
  4. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/pyproject.toml +1 -1
  5. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/auth.py +21 -8
  6. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/tests/test_auth.py +16 -0
  7. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  8. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  9. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  10. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/.github/workflows/ci.yml +0 -0
  11. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/.gitignore +0 -0
  12. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/AGENTS.md +0 -0
  13. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/CLAUDE.md +0 -0
  14. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/CONTRIBUTING.md +0 -0
  15. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/LICENSE +0 -0
  16. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/README.md +0 -0
  17. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/SECURITY.md +0 -0
  18. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/docs/testing-policy.md +0 -0
  19. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/__init__.py +0 -0
  20. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/_exit_codes.py +0 -0
  21. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/_jsonl.py +0 -0
  22. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/_paths.py +0 -0
  23. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/cards.py +0 -0
  24. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/cli.py +0 -0
  25. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/client.py +0 -0
  26. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/imap_otp.py +0 -0
  27. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/ledger.py +0 -0
  28. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/models.py +0 -0
  29. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/src/extendvcc/py.typed +0 -0
  30. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/tests/fixtures/op_credit_card_template.json +0 -0
  31. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/tests/test_cards.py +0 -0
  32. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/tests/test_cli.py +0 -0
  33. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/tests/test_client.py +0 -0
  34. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/tests/test_imap_otp.py +0 -0
  35. {extendvcc_cli-0.1.0 → extendvcc_cli-0.1.1}/tests/test_ledger.py +0 -0
@@ -10,14 +10,15 @@ permissions:
10
10
  jobs:
11
11
  build:
12
12
  strategy:
13
+ # One platform failing must not cancel the others; the release attaches
14
+ # whatever built. Intel macOS (macos-13) is intentionally dropped: the
15
+ # runner pool is scarce and being retired, and pip/pipx covers that case.
16
+ fail-fast: false
13
17
  matrix:
14
18
  include:
15
19
  - os: macos-latest
16
20
  arch: arm64
17
21
  artifact: extendvcc-macos-arm64
18
- - os: macos-13
19
- arch: x86_64
20
- artifact: extendvcc-macos-x86_64
21
22
  - os: ubuntu-latest
22
23
  arch: x86_64
23
24
  artifact: extendvcc-linux-x86_64
@@ -54,6 +55,9 @@ jobs:
54
55
 
55
56
  release:
56
57
  needs: [build, test]
58
+ # Run once builds finish even if a platform failed, as long as tests passed,
59
+ # so a single bad/slow binary never blocks the GitHub release.
60
+ if: ${{ !cancelled() && needs.test.result == 'success' }}
57
61
  runs-on: ubuntu-latest
58
62
  steps:
59
63
  - uses: actions/download-artifact@v4
@@ -10,6 +10,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
10
10
 
11
11
  ---
12
12
 
13
+ ## [0.1.1] - 2026-06-15
14
+
15
+ ### Fixed
16
+
17
+ - First-time login failed at device registration with `Found negative value for salt or password verifier`. The device SRP salt and password verifier are now zero-padded so Cognito never reads them as negative.
18
+ - Authentication errors now include Cognito's error type and message instead of only a status code, so failures report the real reason.
19
+
20
+ ---
21
+
13
22
  ## [0.1.0] - 2026-06-14
14
23
 
15
24
  ### Added
@@ -28,5 +37,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
28
37
  - **Python API:** public re-exports in `extendvcc.__init__` for programmatic use.
29
38
  - **Typed:** `py.typed` marker (PEP 561); type hints throughout.
30
39
 
31
- [Unreleased]: https://github.com/4LAU/extendvcc/compare/v0.1.0...HEAD
40
+ [Unreleased]: https://github.com/4LAU/extendvcc/compare/v0.1.1...HEAD
41
+ [0.1.1]: https://github.com/4LAU/extendvcc/compare/v0.1.0...v0.1.1
32
42
  [0.1.0]: https://github.com/4LAU/extendvcc/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: extendvcc-cli
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Unofficial CLI and Python client for the Extend virtual card API
5
5
  Project-URL: Homepage, https://github.com/4LAU/extendvcc
6
6
  Project-URL: Repository, https://github.com/4LAU/extendvcc
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "extendvcc-cli"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Unofficial CLI and Python client for the Extend virtual card API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -150,7 +150,20 @@ def _raise_for_status(resp: Any, *, kind: str = "auth", path: str | None = None)
150
150
  status_code=status_code,
151
151
  path=path or "",
152
152
  )
153
- raise PayWithExtendAuthError(f"PayWithExtend Cognito request failed with status {status_code}")
153
+ # Cognito error responses carry {"__type": "...", "message": "..."} — surface
154
+ # it so a 400 distinguishes a wrong/expired code from a real flow bug. The body
155
+ # holds only Cognito's own error type/message, no secrets.
156
+ detail = ""
157
+ try:
158
+ body = _response_json(resp)
159
+ if isinstance(body, dict):
160
+ err_type = str(body.get("__type", "")).rsplit("#", 1)[-1]
161
+ message = body.get("message") or body.get("Message") or ""
162
+ detail = " - ".join(part for part in (err_type, message) if part)
163
+ except Exception:
164
+ detail = ""
165
+ suffix = f" ({detail})" if detail else ""
166
+ raise PayWithExtendAuthError(f"PayWithExtend Cognito request failed with status {status_code}{suffix}")
154
167
 
155
168
 
156
169
  def _inspect_account_risk(resp: Any, path: str) -> None:
@@ -284,12 +297,6 @@ def _hex_to_int(value: str) -> int:
284
297
  return int(value, 16)
285
298
 
286
299
 
287
- def _int_to_bytes(value: int) -> bytes:
288
- if value == 0:
289
- return b"\x00"
290
- return value.to_bytes((value.bit_length() + 7) // 8, "big")
291
-
292
-
293
300
  def _hex_to_bytes(value: int | str) -> bytes:
294
301
  return bytes.fromhex(_pad_hex(value))
295
302
 
@@ -454,11 +461,17 @@ def _generate_device_verifier(
454
461
  salt: bytes | None = None,
455
462
  ) -> tuple[str, str]:
456
463
  salt_bytes = salt or secrets.token_bytes(16)
464
+ # Cognito decodes Salt and PasswordVerifier as signed big-endian integers, so a
465
+ # leading high bit is read as negative ("Found negative value for salt or password
466
+ # verifier") and ConfirmDevice 400s. Prepend a 0x00 byte when needed via
467
+ # _hex_to_bytes/_pad_hex, and use the SAME sign-fixed salt for both the x-hash and
468
+ # the wire value so they stay consistent.
469
+ salt_bytes = _hex_to_bytes(salt_bytes.hex())
457
470
  device_username = f"{device_group_key}{device_key}"
458
471
  x_value = _calculate_x(salt_bytes.hex(), device_username, device_password)
459
472
  verifier = pow(G, x_value, N)
460
473
  return (
461
- base64.b64encode(_int_to_bytes(verifier)).decode("ascii"),
474
+ base64.b64encode(_hex_to_bytes(verifier)).decode("ascii"),
462
475
  base64.b64encode(salt_bytes).decode("ascii"),
463
476
  )
464
477
 
@@ -207,6 +207,22 @@ def test_authenticate_handles_password_otp_and_device_registration(tmp_path, mon
207
207
  configure_paths()
208
208
 
209
209
 
210
+ def test_device_verifier_pads_negative_salt_and_verifier() -> None:
211
+ """Cognito decodes Salt/PasswordVerifier as signed big-endian ints; a high bit must be
212
+ 0x00-padded so neither is negative. Regression for the ConfirmDevice 400
213
+ 'Found negative value for salt or password verifier'."""
214
+ salt = b"\x80" + b"\x11" * 15 # high bit set -> negative without padding
215
+ verifier_b64, salt_b64 = auth._generate_device_verifier("grp", "devkey", "pw", salt=salt)
216
+ salt_bytes = base64.b64decode(salt_b64)
217
+ verifier_bytes = base64.b64decode(verifier_b64)
218
+ # High bit clear (first byte < 0x80) keeps Cognito's signed decode non-negative.
219
+ assert salt_bytes[0] < 0x80
220
+ assert verifier_bytes[0] < 0x80
221
+ # The sign-fixed salt is the original 16 bytes with a 0x00 prepended, and that same
222
+ # salt is what gets sent (so it matches the value used in the x-hash).
223
+ assert salt_bytes == b"\x00" + salt
224
+
225
+
210
226
  def test_authenticate_handles_remembered_device_srp(tmp_path, monkeypatch) -> None:
211
227
  configure_paths(state_dir=tmp_path)
212
228
  auth.save_session(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes