/* * test_bookmarks_tree.c — unit tests for the bookmark tree/HMAC d-tag logic * * Tests the pure pieces that don't require a signer, database, or relay * connection: * - HMAC-SHA256 d-tag derivation is deterministic (same key + path → same hex) * - HMAC-SHA256 d-tag derivation is opaque (different paths → uncorrelated) * - is_hmac_d_tag correctly distinguishes 64-hex HMAC tags from legacy * plaintext directory names * * The full trie operations (add/remove/move/rename/delete) require a signer * and database, so they are exercised via the browser's MCP tools at runtime * rather than in this unit test. * * Build (standalone, links against nostr_core_lib): * cc -std=c99 -Wall -Wextra -g -I./nostr_core_lib \ * tests/test_bookmarks_tree.c \ * nostr_core_lib/libnostr_core_x64.a \ * -lsecp256k1 -lssl -lcrypto -lm -o tests/test_bookmarks_tree * ./tests/test_bookmarks_tree */ #include #include #include #include "../nostr_core_lib/nostr_core/nostr_core.h" static int failures = 0; static int passes = 0; #define CHECK(cond, msg) do { \ if (cond) { passes++; } \ else { failures++; fprintf(stderr, "FAIL: %s\n", msg); } \ } while (0) /* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.h. */ #define LABEL "sovereign-browser/bookmarks-folder-id-v1" /* Mirror of path_to_d_tag in src/bookmarks.c (single-step scheme): * d = HMAC-SHA256(privkey, LABEL + ":" + path) */ static char *path_to_d_tag(const unsigned char *privkey, const char *path) { if (path == NULL) path = ""; char data[512]; snprintf(data, sizeof(data), "%s:%s", LABEL, path); unsigned char mac[32]; if (nostr_hmac_sha256(privkey, 32, (const unsigned char *)data, strlen(data), mac) != 0) { return NULL; } char *hex = malloc(65); if (hex == NULL) return NULL; nostr_bytes_to_hex(mac, 32, hex); hex[64] = '\0'; return hex; } /* Mirror of is_hmac_d_tag in src/bookmarks.c. */ static int is_hmac_d_tag(const char *s) { if (s == NULL || strlen(s) != 64) return 0; for (const char *p = s; *p; p++) { if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f'))) return 0; } return 1; } int main(void) { /* Use a fixed test privkey (32 bytes of 0x01..0x20). */ unsigned char privkey[32]; for (int i = 0; i < 32; i++) privkey[i] = (unsigned char)(i + 1); /* ── Determinism: same path → same d tag ────────────────────────── */ char *d1 = path_to_d_tag(privkey, "Work/Projects/Secret"); char *d2 = path_to_d_tag(privkey, "Work/Projects/Secret"); CHECK(d1 != NULL, "path_to_d_tag returned NULL"); CHECK(d2 != NULL, "path_to_d_tag returned NULL (2nd call)"); CHECK(d1 && d2 && strcmp(d1, d2) == 0, "Same path should produce the same d tag (determinism)"); free(d1); free(d2); /* ── Opaqueness: different paths → different d tags ─────────────── */ char *da = path_to_d_tag(privkey, "Work"); char *db = path_to_d_tag(privkey, "Work/Projects"); char *dc = path_to_d_tag(privkey, "Personal"); CHECK(da && db && strcmp(da, db) != 0, "Different paths should produce different d tags (no prefix leakage)"); CHECK(da && dc && strcmp(da, dc) != 0, "Different paths should produce different d tags (2)"); /* The d tag should NOT contain the plaintext path. */ CHECK(da && strstr(da, "Work") == NULL, "d tag should not leak the plaintext path"); CHECK(db && strstr(db, "Work") == NULL, "d tag should not leak the plaintext path (child)"); free(da); free(db); free(dc); /* ── d tag is 64 lowercase hex chars ────────────────────────────── */ char *d = path_to_d_tag(privkey, "General"); CHECK(d != NULL, "path_to_d_tag returned NULL for General"); CHECK(d && strlen(d) == 64, "d tag should be 64 hex chars"); CHECK(d && is_hmac_d_tag(d), "d tag should pass is_hmac_d_tag"); free(d); /* ── is_hmac_d_tag distinguishes legacy names ───────────────────── */ CHECK(is_hmac_d_tag("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), "64 lowercase hex should be recognized as an HMAC d tag"); CHECK(!is_hmac_d_tag("General"), "Legacy plaintext 'General' should NOT be recognized as an HMAC d tag"); CHECK(!is_hmac_d_tag("Work/Projects"), "Legacy plaintext path with slash should NOT be recognized as an HMAC d tag"); CHECK(!is_hmac_d_tag(""), "Empty string should NOT be recognized as an HMAC d tag"); CHECK(!is_hmac_d_tag(NULL), "NULL should NOT be recognized as an HMAC d tag"); /* Uppercase hex is not produced by nostr_bytes_to_hex (lowercase), so * treat it as non-HMAC (legacy). */ CHECK(!is_hmac_d_tag("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), "Uppercase hex should NOT be recognized as an HMAC d tag (we emit lowercase)"); /* ── Different privkeys → different d tags for the same path ────── */ unsigned char privkey2[32]; for (int i = 0; i < 32; i++) privkey2[i] = (unsigned char)(255 - i); char *e1 = path_to_d_tag(privkey, "General"); char *e2 = path_to_d_tag(privkey2, "General"); CHECK(e1 && e2 && strcmp(e1, e2) != 0, "Different privkeys should produce different d tags for the same path"); free(e1); free(e2); /* ── Empty path is allowed (root) ───────────────────────────────── */ char *root_d = path_to_d_tag(privkey, ""); CHECK(root_d != NULL, "path_to_d_tag should handle empty path"); CHECK(root_d && strlen(root_d) == 64, "Empty path d tag should still be 64 hex chars"); free(root_d); printf("bookmarks tree tests: %d passed, %d failed\n", passes, failures); return failures == 0 ? 0 : 1; }