mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 00:12:04 +00:00
The "semantic (vector search)" entry in not_yet_ported was a misnomer. It is
not vector search and needs no model: MODEL_ID is "seedpass-token-overlap-v1",
and it scores by Jaccard overlap over tokenized metadata. That is why it could
be ported at all — there is nothing to embed.
Porting it surfaced two defects in the Python implementation.
**Stored secrets were written to a plaintext, 0664 file.** `_extract_text`
appended a key_value entry's `value` — the stored secret itself — and
`build()` wrote it to semantic_index/records.json in the clear, along with a
tokenized copy that leaked it just as thoroughly. The file was created at the
process umask, so on a default install it was group- and world-readable, next
to a vault that is encrypted and 0600. Anything that could read the directory
— a backup, a sync client, another user on a shared machine — got the secret
without the master password.
Fixed in Python (index the key NAME, never the value; chmod 0600 on both
files) and never done in TypeScript. The rule is worth keeping simple: nobody
searches for a secret they do not already know, so indexing one is pure
downside. MODEL_ID is bumped to v2 so an index built by the old code is
distinguishable — a v1 index on disk still contains the secrets and should be
rebuilt.
**Entry 0 was silently unsearchable.** The check was
`int(entry.get("id", 0) or 0) <= 0`, conflating "no id" with "id 0" — and
entry ids start at 0, so the first entry any user ever created was absent from
every search result with nothing to indicate why. Found by writing a test that
queried for it. Fixed in both implementations: skip an id that is absent,
unparseable or negative, which is what the check was reaching for.
The TypeScript port is wired into the CLI (`semantic build/status/search`),
the API (the /api/v1/semantic routes are now real rather than 501), and
writes through atomicWrite for a genuine 0600 inode. Fixtures come from
scripts/generate_ts_port_fixtures.py, which asserts the leaked value is absent
from its own output, so the fixture-drift job proves provenance the way it
does for every other fixture.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
144 lines
5.8 KiB
TypeScript
144 lines
5.8 KiB
TypeScript
/**
|
|
* The retrieval index, checked against the Python implementation.
|
|
*
|
|
* Two properties matter, and they pull in different directions:
|
|
*
|
|
* 1. It must rank identically to Python, so a query answered on one
|
|
* implementation is answered the same way on the other. Fixtures below
|
|
* are that implementation's actual output.
|
|
* 2. It must never write a secret. The records file is plaintext on disk,
|
|
* so anything indexed is readable by whatever can read the profile
|
|
* directory without the master password. Python used to index a
|
|
* key_value entry's `value`; the fixture proves it no longer does, and
|
|
* the TS side is asserted against the same rule.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildSemanticRecords,
|
|
searchSemanticRecords,
|
|
semanticStatus,
|
|
tokenize,
|
|
SEMANTIC_MODEL_ID,
|
|
type SemanticRecord,
|
|
} from "@seedpass/core";
|
|
import { semanticFixture } from "@seedpass/test-vectors";
|
|
|
|
// Imported through the fixture package rather than read from disk, so the
|
|
// suite also runs under the jsdom environment, where there is no filesystem.
|
|
const fx = semanticFixture;
|
|
|
|
describe("record building matches Python", () => {
|
|
it("produces the same records, in the same order, with the same tokens", () => {
|
|
expect(buildSemanticRecords(fx.entries)).toEqual(fx.records);
|
|
});
|
|
|
|
it("indexes entry 0, and skips only what genuinely has nothing to index", () => {
|
|
const ids = buildSemanticRecords(fx.entries).map((r) => r.entry_id);
|
|
// Entry 0 is the FIRST entry any profile creates. Both implementations
|
|
// used to test `id <= 0` and silently dropped it, so every user's first
|
|
// entry was permanently unfindable with nothing to indicate why.
|
|
expect(ids).toContain(0);
|
|
expect(ids).not.toContain(7); // 'seed' is not an indexed kind
|
|
expect(ids).not.toContain(8); // no text at all
|
|
expect(ids).toContain(9); // a link's relation and note are indexable text
|
|
});
|
|
|
|
it("still skips an entry with no id, which is what the old test meant", () => {
|
|
// The fixture carries one entry with no `id` key. Dropping it is correct;
|
|
// dropping id 0 alongside it was the bug.
|
|
const withoutId = fx.entries.filter((e) => e["id"] === undefined);
|
|
expect(withoutId).toHaveLength(1);
|
|
expect(buildSemanticRecords(withoutId)).toEqual([]);
|
|
});
|
|
|
|
it("finds the first entry a profile ever created", () => {
|
|
const hits = searchSemanticRecords(buildSemanticRecords(fx.entries), "first entry");
|
|
expect(hits.map((h) => h.entry_id)).toContain(0);
|
|
});
|
|
});
|
|
|
|
describe("no secret reaches the index", () => {
|
|
it("omits a key_value entry's value while keeping its key name", () => {
|
|
const records = buildSemanticRecords(fx.entries);
|
|
const serialized = JSON.stringify(records);
|
|
// The fixture entry carries this value; it must not survive indexing.
|
|
expect(serialized).not.toContain("SECRET-MUST-NOT-APPEAR");
|
|
// Tokenized fragments leak it just as well as the whole string.
|
|
for (const fragment of ["secret", "appear"]) {
|
|
expect(serialized.toLowerCase()).not.toContain(fragment);
|
|
}
|
|
// Still findable by the things that are not the secret.
|
|
const kv = records.find((r) => r.entry_id === 2)!;
|
|
expect(kv.text).toContain("DEPLOY_TOKEN");
|
|
expect(kv.text).toContain("ci pipeline");
|
|
});
|
|
|
|
it("indexes only the public half of a nostr entry", () => {
|
|
const nostr = buildSemanticRecords(fx.entries).find((r) => r.entry_id === 6)!;
|
|
expect(nostr.text).toContain("npub1example");
|
|
expect(nostr.text).not.toContain("nsec");
|
|
});
|
|
});
|
|
|
|
describe("search matches Python", () => {
|
|
for (const [query, expected] of Object.entries(fx.queries)) {
|
|
it(`ranks "${query}" identically`, () => {
|
|
const got = searchSemanticRecords(fx.records, query, { k: 10 });
|
|
expect(got).toEqual(expected);
|
|
});
|
|
}
|
|
|
|
it("filters by kind", () => {
|
|
const got = searchSemanticRecords(fx.records, "ops", { k: 10, kind: "ssh" });
|
|
expect(got.map((h) => h.entry_id)).toEqual(fx.kind_filtered.map((h) => h.entry_id));
|
|
});
|
|
|
|
it("returns nothing for an empty query rather than everything", () => {
|
|
// An empty query has no tokens, so nothing overlaps it. Returning the
|
|
// whole vault would be the dangerous reading of "matches everything".
|
|
expect(searchSemanticRecords(fx.records, "")).toEqual([]);
|
|
expect(searchSemanticRecords(fx.records, " ")).toEqual([]);
|
|
});
|
|
|
|
it("honours k, keeping the highest scores", () => {
|
|
const all = searchSemanticRecords(fx.records, "bank account", { k: 10 });
|
|
const one = searchSemanticRecords(fx.records, "bank account", { k: 1 });
|
|
expect(one).toHaveLength(1);
|
|
expect(one[0]).toEqual(all[0]);
|
|
});
|
|
|
|
it("breaks score ties by entry id, so the order is stable", () => {
|
|
const records: SemanticRecord[] = [
|
|
{ entry_id: 9, kind: "document", label: "b", text: "alpha", tokens: ["alpha"] },
|
|
{ entry_id: 2, kind: "document", label: "a", text: "alpha", tokens: ["alpha"] },
|
|
];
|
|
expect(searchSemanticRecords(records, "alpha").map((h) => h.entry_id)).toEqual([2, 9]);
|
|
});
|
|
});
|
|
|
|
describe("tokenizing", () => {
|
|
it("lowercases and splits on non-word characters", () => {
|
|
// Underscore is a word character, so `Login_2FA` is one token rather than
|
|
// two — verified against Python's [a-z0-9_]+ rather than assumed.
|
|
expect([...tokenize("Bank.Example/Login_2FA")].sort()).toEqual([
|
|
"bank",
|
|
"example",
|
|
"login_2fa",
|
|
]);
|
|
});
|
|
|
|
it("is empty for whitespace", () => {
|
|
expect(tokenize(" ").size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("status", () => {
|
|
it("reports the model id that marks a post-fix index", () => {
|
|
// A v1 index was built by code that wrote secrets, so the id has to
|
|
// distinguish them.
|
|
expect(semanticStatus({ built: true, enabled: true }, 3).model_id).toBe(SEMANTIC_MODEL_ID);
|
|
expect(SEMANTIC_MODEL_ID).toBe("seedpass-token-overlap-v2");
|
|
});
|
|
});
|