mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
* Recover KakaoTalk mac skill auth when upstream user_id detection fails Issue #121 reproduces on a real MacBook because `kakaocli auth` can fail even when the encrypted hex-named DB exists. This change adds a thin repo-owned helper that recovers the active user_id from plist revision hashes, caches the validated DB/key tuple, and reuses it for read-only `kakaocli` commands. The skill and feature docs now steer users to the helper when upstream auto-detection stops at candidate key mismatch, and regression tests lock the recovery flow before the implementation. Constraint: Must stay a thin adapter around upstream kakaocli rather than forking the CLI Constraint: Must verify on a real local macOS KakaoTalk install where issue #121 reproduces Rejected: Full kakaocli reimplementation inside k-skill | too broad for the user_id/key-derivation failure scope Rejected: Docs-only workaround | does not actually fix the broken auth path for users Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep this helper limited to auth/key recovery and read-only passthrough unless upstream gaps widen materially Tested: python3 -m unittest scripts.test_kakaotalk_mac Tested: node --test scripts/skill-docs.test.js Tested: npm run ci Tested: python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000 Tested: python3 scripts/kakaotalk_mac.py chats --limit 1 --json Not-tested: Other kakaocli subcommands beyond auth/chats/messages/search/query/schema * Protect the KakaoTalk helper's safe recovery path Address the PR follow-up by treating malformed auth cache files as cache misses, removing write-capable passthrough from the wrapper surface, and redacting human-readable auth output so the cached SQLCipher key is not echoed back into terminal history. The docs and regression suite now describe and enforce the read-only contract that the helper is meant to preserve. Constraint: Helper must remain a read-only recovery wrapper around local kakaocli access Rejected: Keep query support with SQL validation | still leaves a risky write-capable escape hatch Confidence: high Scope-risk: narrow Reversibility: clean Directive: Do not re-expose arbitrary SQL passthrough or print the SQLCipher key in default text output Tested: python3 -m unittest scripts.test_kakaotalk_mac; node --test scripts/skill-docs.test.js; npm run ci; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000; python3 scripts/kakaotalk_mac.py chats --limit 1 --json; python3 scripts/kakaotalk_mac.py auth --cache-path <bad-json>; python3 scripts/kakaotalk_mac.py query --help Not-tested: External automation consumers that depend on shell/json auth output beyond the documented helper flows * Lock the helper CLI surface against accidental regressions The approved issue #121 fixes already hardened the KakaoTalk Mac helper, but the test suite still only exercised the passthrough validator directly. Add an explicit parser-level regression so the public CLI contract stays read-only and `query` cannot quietly reappear in future edits. Constraint: Follow-up is on the existing feature/#121 PR branch and must stay minimal Rejected: Re-open helper implementation changes | current code already satisfies the approved review findings Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep parser exposure tests aligned with READ_ONLY_COMMANDS whenever helper subcommands change Tested: python3 -m unittest scripts.test_kakaotalk_mac; node --test scripts/skill-docs.test.js; npm run ci; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000; python3 scripts/kakaotalk_mac.py chats --limit 1 --json; python3 scripts/kakaotalk_mac.py auth --cache-path <bad-json> Not-tested: No new production code paths changed in this follow-up * Honor explicit Kakao auth recovery overrides The helper now treats manual auth overrides as a cache-bypassing recovery request and rejects invalid brute-force tuning flags at the CLI boundary so users get deterministic behavior instead of stale cached tuples or Python tracebacks. Regression coverage locks both paths before the PR follow-up lands. Constraint: The helper must remain a thin read-only wrapper around kakaocli auth recovery Rejected: Require --refresh whenever --user-id/--uuid is passed | worse UX than honoring overrides directly Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep explicit auth overrides ahead of cache reuse unless the CLI contract is redesigned and documented Tested: python3 -m unittest scripts.test_kakaotalk_mac; node --test scripts/skill-docs.test.js; npm run ci; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000; python3 scripts/kakaotalk_mac.py chats --limit 1 --json; python3 scripts/kakaotalk_mac.py auth --cache-path <bad-json>; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id -1; python3 scripts/kakaotalk_mac.py auth --refresh --workers 2 --chunk-size 0 --max-user-id 10; python3 scripts/kakaotalk_mac.py auth --cache-path <temp-cache> --user-id 999; python3 scripts/kakaotalk_mac.py auth --cache-path <temp-cache> --uuid <live-uuid> Not-tested: Manual override success with a truly alternate valid user_id/uuid pair on a multi-account local install
273 lines
11 KiB
Python
273 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import io
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import scripts.kakaotalk_mac as kakaotalk_mac
|
|
|
|
|
|
def sha512_hex(value: int) -> str:
|
|
return hashlib.sha512(str(value).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def make_resolved_auth(
|
|
*,
|
|
user_id: int = 123,
|
|
uuid: str = "uuid",
|
|
database_path: Path | None = None,
|
|
database_name: str = "db-name",
|
|
key: str = "super-secret",
|
|
source: str = "cache",
|
|
) -> kakaotalk_mac.ResolvedAuth:
|
|
return kakaotalk_mac.ResolvedAuth(
|
|
user_id=user_id,
|
|
uuid=uuid,
|
|
database_path=database_path or Path("/tmp/kakaotalk.db"),
|
|
database_name=database_name,
|
|
key=key,
|
|
source=source,
|
|
)
|
|
|
|
|
|
class KakaoTalkMacHelperTests(unittest.TestCase):
|
|
def test_parse_plist_xml_extracts_candidates_and_active_hash(self) -> None:
|
|
active_hash = sha512_hex(123456)
|
|
xml_text = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>AlertKakaoIDsList</key>
|
|
<array>
|
|
<integer>111</integer>
|
|
<integer>222</integer>
|
|
</array>
|
|
<key>userId</key>
|
|
<integer>333</integer>
|
|
<key>DESIGNATEDFRIENDSREVISION:{active_hash}</key>
|
|
<integer>5</integer>
|
|
</dict>
|
|
</plist>
|
|
"""
|
|
|
|
parsed = kakaotalk_mac.parse_plist_xml(xml_text)
|
|
|
|
self.assertEqual(parsed["AlertKakaoIDsList"], [111, 222])
|
|
self.assertEqual(kakaotalk_mac.collect_candidate_user_ids(parsed), [333, 111, 222])
|
|
self.assertEqual(kakaotalk_mac.find_active_account_hash(parsed), active_hash)
|
|
|
|
def test_discover_database_files_filters_hex_names(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
root = Path(tempdir)
|
|
expected = [
|
|
root / ("a" * 78),
|
|
root / ("b" * 78 + ".db"),
|
|
]
|
|
for path in expected:
|
|
path.write_text("", encoding="utf-8")
|
|
(root / ("c" * 40)).write_text("", encoding="utf-8")
|
|
(root / ("d" * 78 + "-wal")).write_text("", encoding="utf-8")
|
|
|
|
discovered = kakaotalk_mac.discover_database_files(root)
|
|
|
|
self.assertEqual(discovered, expected)
|
|
|
|
def test_recover_user_id_from_sha512_supports_single_worker_search(self) -> None:
|
|
target_user_id = 123456
|
|
recovered = kakaotalk_mac.recover_user_id_from_sha512(
|
|
sha512_hex(target_user_id),
|
|
max_user_id=200000,
|
|
workers=1,
|
|
chunk_size=5000,
|
|
)
|
|
|
|
self.assertEqual(recovered, target_user_id)
|
|
|
|
def test_resolve_auth_retries_with_hash_recovered_user_id_and_caches_result(self) -> None:
|
|
target_user_id = 654321
|
|
active_hash = sha512_hex(target_user_id)
|
|
|
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
cache_path = Path(tempdir) / "auth-cache.json"
|
|
database_path = Path(tempdir) / "kakaotalk.db"
|
|
database_path.write_text("", encoding="utf-8")
|
|
verification_calls: list[int] = []
|
|
|
|
state = kakaotalk_mac.DetectionState(
|
|
uuid="42C34717-27C3-538C-81E4-8B568287C7A0",
|
|
candidate_user_ids=[111, 222],
|
|
active_account_hash=active_hash,
|
|
database_files=[database_path],
|
|
)
|
|
|
|
def verify(candidate: kakaotalk_mac.ResolvedAuth) -> bool:
|
|
verification_calls.append(candidate.user_id)
|
|
return candidate.user_id == target_user_id
|
|
|
|
resolved = kakaotalk_mac.resolve_auth_state(
|
|
state,
|
|
verify_access=verify,
|
|
cache_path=cache_path,
|
|
max_user_id=700000,
|
|
workers=1,
|
|
chunk_size=10000,
|
|
)
|
|
|
|
cache_payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
|
|
self.assertEqual(verification_calls, [111, 222, target_user_id])
|
|
self.assertEqual(resolved.user_id, target_user_id)
|
|
self.assertEqual(resolved.database_path, database_path)
|
|
self.assertEqual(cache_payload["user_id"], target_user_id)
|
|
self.assertEqual(cache_payload["database_path"], str(database_path))
|
|
|
|
def test_load_cached_auth_treats_corrupt_json_as_cache_miss(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
cache_path = Path(tempdir) / "auth-cache.json"
|
|
cache_path.write_text("{bad json\n", encoding="utf-8")
|
|
|
|
self.assertIsNone(kakaotalk_mac.load_cached_auth(cache_path))
|
|
|
|
def test_resolve_auth_reuses_detection_when_cache_is_corrupt(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
cache_path = Path(tempdir) / "auth-cache.json"
|
|
cache_path.write_text("{bad json\n", encoding="utf-8")
|
|
database_path = Path(tempdir) / "kakaotalk.db"
|
|
database_path.write_text("", encoding="utf-8")
|
|
resolved = make_resolved_auth(database_path=database_path, source="hash-recovery")
|
|
|
|
with (
|
|
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
|
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=resolved) as resolve_state,
|
|
):
|
|
cached = kakaotalk_mac.resolve_auth(
|
|
refresh=False,
|
|
cache_path=cache_path,
|
|
user_id_override=None,
|
|
uuid_override=None,
|
|
max_user_id=1000,
|
|
workers=1,
|
|
chunk_size=100,
|
|
)
|
|
|
|
self.assertEqual(cached, resolved)
|
|
collect_state.assert_called_once_with(None)
|
|
resolve_state.assert_called_once()
|
|
|
|
def test_resolve_auth_bypasses_cache_when_user_id_override_is_supplied(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
cache_path = Path(tempdir) / "auth-cache.json"
|
|
database_path = Path(tempdir) / "kakaotalk.db"
|
|
database_path.write_text("", encoding="utf-8")
|
|
persistable = make_resolved_auth(database_path=database_path, source="cache")
|
|
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
|
|
override_result = make_resolved_auth(user_id=999, database_path=database_path, source="candidate")
|
|
|
|
with (
|
|
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
|
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
|
|
):
|
|
resolved = kakaotalk_mac.resolve_auth(
|
|
refresh=False,
|
|
cache_path=cache_path,
|
|
user_id_override=999,
|
|
uuid_override=None,
|
|
max_user_id=1000,
|
|
workers=1,
|
|
chunk_size=100,
|
|
)
|
|
|
|
self.assertEqual(resolved, override_result)
|
|
collect_state.assert_called_once_with(None)
|
|
resolve_state.assert_called_once_with(
|
|
mock.sentinel.state,
|
|
verify_access=kakaotalk_mac.verify_database_access,
|
|
cache_path=cache_path,
|
|
user_id_override=999,
|
|
max_user_id=1000,
|
|
workers=1,
|
|
chunk_size=100,
|
|
)
|
|
|
|
def test_resolve_auth_bypasses_cache_when_uuid_override_is_supplied(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
cache_path = Path(tempdir) / "auth-cache.json"
|
|
database_path = Path(tempdir) / "kakaotalk.db"
|
|
database_path.write_text("", encoding="utf-8")
|
|
persistable = make_resolved_auth(database_path=database_path, source="cache")
|
|
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
|
|
override_result = make_resolved_auth(uuid="override-uuid", database_path=database_path, source="candidate")
|
|
|
|
with (
|
|
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
|
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
|
|
):
|
|
resolved = kakaotalk_mac.resolve_auth(
|
|
refresh=False,
|
|
cache_path=cache_path,
|
|
user_id_override=None,
|
|
uuid_override="override-uuid",
|
|
max_user_id=1000,
|
|
workers=1,
|
|
chunk_size=100,
|
|
)
|
|
|
|
self.assertEqual(resolved, override_result)
|
|
collect_state.assert_called_once_with("override-uuid")
|
|
resolve_state.assert_called_once_with(
|
|
mock.sentinel.state,
|
|
verify_access=kakaotalk_mac.verify_database_access,
|
|
cache_path=cache_path,
|
|
user_id_override=None,
|
|
max_user_id=1000,
|
|
workers=1,
|
|
chunk_size=100,
|
|
)
|
|
|
|
def test_render_auth_text_redacts_key_material(self) -> None:
|
|
resolved = make_resolved_auth(key="super-secret-key", source="hash-recovery")
|
|
|
|
rendered = kakaotalk_mac.render_auth(resolved, output_format="text", cache_path=Path("/tmp/cache.json"))
|
|
|
|
self.assertNotIn("super-secret-key", rendered)
|
|
self.assertNotIn("--key", rendered)
|
|
self.assertIn("python3 scripts/kakaotalk_mac.py chats --limit 10 --json", rendered)
|
|
|
|
def test_build_passthrough_command_rejects_non_read_only_command(self) -> None:
|
|
auth = make_resolved_auth()
|
|
|
|
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
|
|
kakaotalk_mac.build_passthrough_command("query", auth, ["DELETE FROM chat_logs"])
|
|
|
|
def test_build_parser_only_exposes_read_only_commands(self) -> None:
|
|
parser = kakaotalk_mac.build_parser()
|
|
subcommands = parser._subparsers._group_actions[0].choices
|
|
|
|
self.assertEqual(sorted(subcommands), ["auth", "chats", "messages", "schema", "search"])
|
|
self.assertNotIn("query", subcommands)
|
|
|
|
def test_build_parser_rejects_negative_max_user_id(self) -> None:
|
|
parser = kakaotalk_mac.build_parser()
|
|
stderr = io.StringIO()
|
|
|
|
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
|
|
parser.parse_args(["auth", "--max-user-id", "-1"])
|
|
|
|
self.assertEqual(exit_context.exception.code, 2)
|
|
self.assertIn("must be non-negative", stderr.getvalue())
|
|
|
|
def test_build_parser_rejects_non_positive_chunk_size(self) -> None:
|
|
parser = kakaotalk_mac.build_parser()
|
|
stderr = io.StringIO()
|
|
|
|
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
|
|
parser.parse_args(["auth", "--chunk-size", "0"])
|
|
|
|
self.assertEqual(exit_context.exception.code, 2)
|
|
self.assertIn("must be positive", stderr.getvalue())
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|