Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,12 @@ pass" and let the user confirm.
string — they fail OXT compilation. ASCII `"` and `'` only. The static checker
enforces zero.
2. **Avoid names whose stem shadows an engine token** even when prefixed; prefer
distinctive, multi-word stems.
distinctive, multi-word stems. The nastiest case is a prefixed name whose
*full spelling* IS a reserved token: `tExt` (t + "Ext" for extension) is
literally `t-e-x-t` = `text`, so xTalk evaluates it as the `text` keyword, not
a variable — it compiles and silently misbehaves. `tools/check-livecodescript.py`
now flags this class (any `t/p/s/k`-prefixed name that lowercases to a reserved
word); use a different stem (e.g. `tSuffix`).
3. **Prefix conventions:** `t` handler-local, `p` parameter, `s` script/module-local,
`k` constant. Public API `btPascalCase`; C ABI `btx_snake_case`.
4. **Constants must be literal** and declared **before first use** (OXT resolves them by
Expand Down
369 changes: 321 additions & 48 deletions examples/torrent-dht-channels.livecodescript

Large diffs are not rendered by default.

71 changes: 68 additions & 3 deletions src/torrent_shim.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2121,6 +2121,27 @@ extern "C" BTX_API int BTX_CALL btx_dht_get_immutable(int s, const char *targetH
});
}

/* Sign a mutable-item STRING value the BEP44 way, setting the entry `e` that
* libtorrent will store. THE SIGNATURE MUST COVER THE BENCODED VALUE (a string
* "hi" bencodes to "2:hi"), because that is exactly what every storing node and
* every getter verifies it against — signing the raw bytes yields a signature
* that verifies against nothing, so nodes reject the store and getters never
* surface the item, and a whole signed channel feed silently disappears.
* Mirrors libtorrent's own examples/dht_put.cpp. `out_signed` receives the
* bencoded buffer that was signed so a caller (the smoke test) can re-verify
* with verify_mutable_item; the production put callback and that test hook both
* route through here, so the test exercises the REAL signing path. */
static lt::dht::signature sign_mutable_string_entry(
lt::entry &e, const std::vector<char> &val, const std::string &salt,
std::int64_t seq, const lt::dht::public_key &pk,
const lt::dht::secret_key &sk, std::vector<char> &out_signed) {
e = lt::entry(std::string(val.begin(), val.end()));
out_signed.clear();
lt::bencode(std::back_inserter(out_signed), e);
return lt::dht::sign_mutable_item(out_signed, salt,
lt::dht::sequence_number(seq), pk, sk);
}

extern "C" BTX_API int BTX_CALL btx_dht_put_mutable(int s, const char *publicKeyHex,
const char *secretKeyHex,
const char *salt,
Expand All @@ -2143,10 +2164,10 @@ extern "C" BTX_API int BTX_CALL btx_dht_put_mutable(int s, const char *publicKey
st->ses->dht_put_item(pk.bytes,
[val, salt_s, pk, sk](lt::entry &e, std::array<char, 64> &sig,
std::int64_t &seq, std::string const &) {
e = lt::entry(std::string(val.begin(), val.end()));
seq = seq + 1; /* monotonic: bump past the current value */
lt::dht::signature sg = lt::dht::sign_mutable_item(
val, salt_s, lt::dht::sequence_number(seq), pk, sk);
std::vector<char> signed_buf; /* the bencoded value we signed */
lt::dht::signature sg = sign_mutable_string_entry(
e, val, salt_s, seq, pk, sk, signed_buf);
sig = sg.bytes;
}, salt_s);
return BTX_OK; /* confirmation -> A_DHT_PUT alert */
Expand Down Expand Up @@ -2281,5 +2302,49 @@ int live_session_count(void) {
return static_cast<int>(g_sessions.live_count());
}

int dht_mutable_sign_verifies(const char *publicKeyHex, const char *secretKeyHex,
const char *salt, const void *data, int len) {
/* Sign a mutable value through the SAME helper the production put uses, then
* check it with libtorrent's own verify_mutable_item — the exact gate a
* follower's libtorrent applies before surfacing the item. A pass proves the
* BEP44 signing contract holds (sign the bencoded value); a regression here
* is the silent "feeds never arrive" failure. Wrapped in the firewall like
* every entry, so a throw becomes a negative code, never a CHECK crash. */
BTX_GUARD_ACTION({
lt::dht::public_key pk;
lt::dht::secret_key sk;
if (!hex_to_buf(publicKeyHex, pk.bytes.data(), 32)) return 0;
if (!hex_to_buf(secretKeyHex, sk.bytes.data(), 64)) return 0;
std::string salt_s = salt ? salt : "";
const char *p = static_cast<const char *>(data);
std::vector<char> val(p, p + (len < 0 ? 0 : len));
lt::entry e;
std::vector<char> signed_buf; /* the bencoded value v that was signed */
const std::int64_t seq = 1;
lt::dht::signature sg =
sign_mutable_string_entry(e, val, salt_s, seq, pk, sk, signed_buf);
/* Reconstruct the BEP44 canonical signed message exactly as a remote
* verifier does — [4:salt<len>:<salt>] 3:seqi<seq>e 1:v <bencoded v> —
* and check the signature with ed25519_verify, the same primitive a
* follower's libtorrent applies. (verify_mutable_item itself is
* TORRENT_EXTRA_EXPORT, not in the shared lib, so we use the public
* ed25519 verify against the canonical string instead.) If the value was
* signed bencoded (correct) this passes; if signed raw (the bug) it
* fails — so this assertion is the regression guard for that silent bug. */
std::string canon;
if (!salt_s.empty()) {
canon += "4:salt";
canon += std::to_string(salt_s.size());
canon += ":";
canon += salt_s;
}
canon += "3:seqi";
canon += std::to_string(seq);
canon += "e1:v";
canon.append(signed_buf.begin(), signed_buf.end());
return lt::dht::ed25519_verify(sg, canon, pk) ? 1 : 0;
});
}

} // namespace test
} // namespace btx
13 changes: 13 additions & 0 deletions src/torrent_shim.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ BTX_API int force_throw(void);
* visibility reason as force_throw above. */
BTX_API int live_session_count(void);

/* Sign a mutable DHT value through the production signing helper and verify it
* with libtorrent's verify_mutable_item; returns 1 if the signature verifies, 0
* if not (negative on an internal throw). Guards the BEP44 signing contract -
* the value must be signed in its BENCODED form - which is otherwise unreachable
* through the public ABI because real signing only runs in a network-thread
* callback once a live DHT finds a home for the blob. A regression here is the
* silent "channel feeds never arrive" failure. Exported (BTX_API) for the same
* visibility reason as the hooks above. */
BTX_API int dht_mutable_sign_verifies(const char *publicKeyHex,
const char *secretKeyHex,
const char *salt,
const void *data, int len);

} // namespace test
} // namespace btx

Expand Down
6 changes: 5 additions & 1 deletion tests/torrent-selftest.livecodescript
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ command stRun
exit stRun
end if
stAssert "btStartSession returns a positive handle", (sSession > 0)
stAssert "btLastError is a string", (btLastError() is a string)
-- NB: LiveCode's `is a <type>` only knows number/integer/boolean/point/rect/
-- date/color - there is NO `is a string`. Test btLastError by clearing it and
-- confirming it reads back empty (exercises btClearError + btLastError).
btClearError
stAssert "btLastError is empty after btClearError", (btLastError() is empty)

-- Wrap the rest: a mis-declared foreign handler (wrong signature) raises a
-- runtime error on its first call. Catch it so the harness REPORTS which call
Expand Down
13 changes: 13 additions & 0 deletions tests/torrent_smoke_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,19 @@ static void test_dht_bep44() {
CHECK(btx_dht_get_mutable(s, pubHex.c_str(), "myapp") == BTX_OK);
CHECK(btx_dht_get_mutable(s, pubHex.c_str(), "") == BTX_OK); /* empty salt */

/* The BEP44 SIGNING CONTRACT (the bug that made every channel feed silently
* fail): a mutable value must be signed over its BENCODED form, the way a
* follower's libtorrent verifies it before surfacing the item. btx_dht_put_*
* only returns OK that the call wired up - the signing itself runs later in a
* network-thread callback - so this hook signs through the SAME helper and
* checks it with libtorrent's own verify_mutable_item. A real feed string,
* with empty and non-empty salt, must both verify. */
const char *feed = "name=Alice\nr=Demo Release\tmagnet:?xt=urn:btih:0123456789abcdef";
const int feedlen = static_cast<int>(std::strlen(feed));
CHECK(btx::test::dht_mutable_sign_verifies(pubHex.c_str(), secHex.c_str(), "", feed, feedlen) == 1);
CHECK(btx::test::dht_mutable_sign_verifies(pubHex.c_str(), secHex.c_str(), "myapp", feed, feedlen) == 1);
CHECK(btx::test::dht_mutable_sign_verifies(pubHex.c_str(), secHex.c_str(), "", val, vlen) == 1);

/* bad hex / wrong key length / oversize value -> clean BTX_ERR_INVALID_ARG. */
CHECK(btx_dht_get_immutable(s, "not-hex") == BTX_ERR_INVALID_ARG);
CHECK(btx_dht_get_mutable(s, "short", "") == BTX_ERR_INVALID_ARG);
Expand Down
48 changes: 48 additions & 0 deletions tools/check-livecodescript.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
must be wrapped.
5. Constants declared before first use (.lcb) - OXT resolves constants by
lexical position; a forward reference silently evaluates to nothing.
6. No prefixed name that spells a reserved token (both dialects) - e.g. `tExt`
is t-e-x-t = `text`, which xTalk evaluates as the keyword, not a variable.

It is a lexer-level checker, NOT a compiler: it neutralizes comments and strings
and reasons about block keywords. It errs toward NOT raising false positives;
Expand Down Expand Up @@ -371,6 +373,50 @@ def check_lcb_lowercase_names(path, cleaned):
return problems


# A prefixed CamelCase name (t/p/s/k + UpperCamel, the project convention) whose
# FULL lowercased spelling IS a reserved xTalk token. The classic trap: `tExt`
# (intended as t + "Ext" for extension) spells t-e-x-t = `text`, so xTalk
# evaluates it as the `text` keyword, not a variable - a silent, hair-pulling
# bug that compiles. Only reserved words that BEGIN with a prefix letter
# (t/p/s/k) can ever collide, so that is the entire set we need to carry. The
# shape guard `[tpsk][A-Z]` means a normally-written lowercase keyword (`text`,
# `the`, `put`) never matches - only an accidentally-keyword-spelling identifier
# does, and such a name is always a real bug (the engine token wins every time).
PREFIXED_TOKEN_SHADOWS = {
# t-
"the", "then", "this", "there", "to", "text", "time", "title", "top",
"tab", "tan", "true", "target",
# p-
"pi", "pass", "put", "params", "print",
# s-
"send", "set", "sin", "sqrt", "sort", "space", "start", "stop", "stack",
"script", "selection",
# k-
"keys",
}


def check_prefixed_token_shadows(path, cleaned):
"""Flag a t/p/s/k-prefixed name that spells a reserved token (e.g. tExt ->
`text`). Applies to both .lcb and .livecodescript - the shadowing is an
xTalk evaluation rule, not a dialect quirk."""
problems = []
seen = set()
pat = re.compile(r"\b([tpsk][A-Z][A-Za-z0-9_]*)\b")
for lineno, line in cleaned:
for m in pat.finditer(line):
name = m.group(1)
if name in seen:
continue
if name.lower() in PREFIXED_TOKEN_SHADOWS:
seen.add(name)
problems.append(Problem(path, lineno,
"name `%s` spells the reserved token `%s` - xTalk evaluates it "
"as that keyword, not a variable; rename it with a distinctive, "
"multi-word stem (e.g. tExt -> tSuffix)" % (name, name.lower())))
return problems


def check_file(path):
with open(path, "rb") as f:
raw = f.read()
Expand All @@ -395,6 +441,8 @@ def check_file(path):
problems += check_lcb_lowercase_names(path, cleaned)
else:
problems += check_livecodescript_blocks(path, cleaned)
# universal xTalk rule (both dialects): no name that spells a reserved token
problems += check_prefixed_token_shadows(path, cleaned)
return problems


Expand Down
Loading