mirror of
https://github.com/quartz-community/encrypted-pages.git
synced 2026-07-22 03:00:24 +00:00
feat: add stealth: true frontmatter for permanently hidden encrypted pages
Stealth pages are encrypted pages that are hidden from every listing surface permanently, even after a successful client-side decryption. Their metadata is never written to the shadow content index, so there is nothing for the client to recover -- the page remains invisible to graph, explorer, search, backlinks, recent-notes, folder-page, and tag-page for the entire browser session. This fills a gap between "unlisted" (hidden pre-decryption, revealed post-decryption via shadow index) and the desire for "secret door" pages that should only be accessible to users who already know the exact URL. Semantics: - `stealth: true` implies `unlisted: true`. Setting stealth on a page automatically hides it from listings regardless of the `unlisted` field or the `unlistWhenEncrypted` plugin option. - `stealth: true` overrides explicit `unlisted: false`. A user who writes `stealth: true, unlisted: false` gets a hidden stealth page, because stealth without unlisted is semantically incoherent. - `stealth: true` has no effect on non-encrypted pages. Stealth only controls shadow-index emission, which only happens for encrypted unlisted pages. Non-encrypted pages with `stealth: true` are silently ignored (use `unlisted: true` for non-encrypted hiding, via the `unlisted-pages` plugin). - Non-boolean stealth values are ignored. Implementation: - transformer.ts: after setting `data.unlisted`, check if `frontmatter.stealth === true` and if so set `data.stealth = true` and force `data.unlisted = true`. - emitter.ts: in the shadow-index loop, skip any page where `data.stealth === true`. Tests: 8 new (6 transformer + 2 emitter) covering stealth -> unlisted implication, stealth overriding explicit unlisted: false, stealth on non-encrypted pages being a no-op, non-boolean stealth values being ignored, stealth pages being excluded from the shadow index while non-stealth pages pass through unchanged, and mixed stealth/non-stealth content being correctly partitioned. 29 tests total passing.
This commit is contained in:
parent
bcd9743521
commit
8328b55648
8 changed files with 137 additions and 7 deletions
|
|
@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- `EncryptedContentIndex` emitter that writes a versioned shadow content index (`static/encryptedContentIndex.json`) containing opaque encrypted blobs of per-page metadata (slug, title, links, tags) for pages marked `unlisted: true`. The shadow index uses a flat array format so no slugs or titles leak to anonymous visitors.
|
||||
- `unlistWhenEncrypted` option on the `EncryptedPages` transformer. When `true`, encrypted pages are marked `file.data.unlisted = true`, hiding them from every build-time listing surface that respects the `unlisted` convention (contentIndex, RSS, sitemap, backlinks, recent-notes, folder-page, tag-page, graph, explorer, search) while still emitting the HTML so the page remains accessible by direct URL.
|
||||
- Per-page `unlisted: true | false` frontmatter override. Explicit `unlisted: false` forces the page listed even when `unlistWhenEncrypted` is set.
|
||||
- Per-page `stealth: true` frontmatter field. Stealth pages are encrypted pages that are hidden from every listing surface **permanently**, even after a successful client-side decryption — their metadata is never written to the shadow content index, so there is nothing for the client to recover. Useful for "secret door" pages that should only be accessible to users who already know the exact URL. `stealth: true` implies `unlisted: true` and overrides any explicit `unlisted: false`. No effect on non-encrypted pages.
|
||||
- Client-side shadow-index unlocking: on every page load with cached passwords, the client script fetches the shadow index, decrypts entries with cached passwords, patches the resolved `fetchData` object in place, and dispatches `content-index-updated` so graph, explorer, and search re-initialize with the unlocked pages.
|
||||
- `encryptAesGcm`, `decrypt`, and `SHADOW_INDEX_VERSION` exports for test and extension use.
|
||||
- `ShadowIndexBlob`, `ShadowIndexFile`, and `ShadowContentIndexEntry` type exports.
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -69,10 +69,11 @@ All options below are set at the single plugin-entry level and are shared betwee
|
|||
|
||||
### Frontmatter fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `password` | `string` | Enables encryption for this page. The field name is configurable via `passwordField`. |
|
||||
| `unlisted` | `boolean` | Overrides `unlistWhenEncrypted`. `true` forces the page unlisted; `false` forces it listed even when `unlistWhenEncrypted` is also set. |
|
||||
| Field | Type | Description |
|
||||
| ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `password` | `string` | Enables encryption for this page. The field name is configurable via `passwordField`. |
|
||||
| `unlisted` | `boolean` | Overrides `unlistWhenEncrypted`. `true` forces the page unlisted; `false` forces it listed even when `unlistWhenEncrypted` is also set. |
|
||||
| `stealth` | `boolean` | When `true` and the page is encrypted, suppresses the page's entry in the shadow content index. The page stays hidden from every listing surface **even after successful client-side decryption**. Implies `unlisted: true` (overrides any explicit `unlisted: false`). No effect on non-encrypted pages. |
|
||||
|
||||
## How `unlisted` works
|
||||
|
||||
|
|
@ -84,6 +85,27 @@ When an encrypted page is marked `unlisted: true`:
|
|||
- On any page load, if the user has cached passwords in sessionStorage from a previous successful decryption, the client script fetches the shadow index, decrypts entries matching those passwords, and patches the in-memory content index in place. A `content-index-updated` event is dispatched so graph, explorer, and search reinitialize with the unlocked entries.
|
||||
- Server-side rendered listings (backlinks, recent-notes, folder-page, tag-page) remain statically hidden even after client-side decryption. This is a deliberate trade-off: those surfaces are HTML baked at build time and cannot be patched client-side.
|
||||
|
||||
## How `stealth` works
|
||||
|
||||
When an encrypted page is marked `stealth: true`, it behaves like an unlisted page with one critical difference: its entry is **never** emitted to the shadow content index. As a result:
|
||||
|
||||
- The page HTML is still emitted at its normal URL and accessible to anyone who knows it.
|
||||
- The page is absent from `contentIndex.json`, RSS, sitemap, graph, explorer, search, backlinks, recent notes, folder listings, and tag listings — same as any `unlisted` page.
|
||||
- Unlike `unlisted`, **successful client-side decryption does not reveal the page in graph, explorer, or search**. There is no shadow-index entry to decrypt, so the in-memory content index is never patched for this page.
|
||||
- The password cache still works: a user who unlocks a stealth page once will not have to re-enter the password on subsequent visits within the same session. Only discovery surfaces are affected.
|
||||
|
||||
Use `stealth: true` when you want a "secret door" page: accessible only to users who already know the exact URL, invisible to everyone else permanently, even to users who have successfully decrypted other encrypted pages on the same site.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Deep Secret
|
||||
password: hunter2
|
||||
stealth: true
|
||||
---
|
||||
```
|
||||
|
||||
Setting `stealth: true` is equivalent to `unlisted: true` on a page that does not also have a `password` field — the flag has no effect on non-encrypted pages, because stealth only controls the shadow-index emission, and non-encrypted pages do not have shadow-index entries in the first place.
|
||||
|
||||
## Security
|
||||
|
||||
- AES-256-GCM with 16-byte salt, 12-byte IV, 16-byte auth tag.
|
||||
|
|
|
|||
5
dist/index.js
vendored
5
dist/index.js
vendored
|
|
@ -2274,6 +2274,10 @@ var rehypeEncryptedPages = (options) => {
|
|||
} else if (options.unlistWhenEncrypted) {
|
||||
data.unlisted = true;
|
||||
}
|
||||
if (frontmatter.stealth === true) {
|
||||
data.stealth = true;
|
||||
data.unlisted = true;
|
||||
}
|
||||
};
|
||||
};
|
||||
var EncryptedPages = (userOptions) => {
|
||||
|
|
@ -2320,6 +2324,7 @@ var EncryptedContentIndex = (userOptions) => {
|
|||
const data = file.data ?? {};
|
||||
if (data.encrypted !== true) continue;
|
||||
if (data.unlisted !== true) continue;
|
||||
if (data.stealth === true) continue;
|
||||
const frontmatter = data.frontmatter ?? {};
|
||||
const password = frontmatter[passwordField];
|
||||
if (typeof password !== "string" || password.length === 0) continue;
|
||||
|
|
|
|||
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -84,6 +84,7 @@ export const EncryptedContentIndex: QuartzEmitterPlugin<Partial<EncryptedContent
|
|||
const data = (file.data ?? {}) as Record<string, unknown>;
|
||||
if (data.encrypted !== true) continue;
|
||||
if (data.unlisted !== true) continue;
|
||||
if (data.stealth === true) continue;
|
||||
|
||||
const frontmatter = (data.frontmatter as Record<string, unknown> | undefined) ?? {};
|
||||
const password = frontmatter[passwordField];
|
||||
|
|
|
|||
|
|
@ -88,6 +88,11 @@ const rehypeEncryptedPages = (options: EncryptedPagesOptions): Plugin<[], HastRo
|
|||
} else if (options.unlistWhenEncrypted) {
|
||||
data.unlisted = true;
|
||||
}
|
||||
|
||||
if (frontmatter.stealth === true) {
|
||||
data.stealth = true;
|
||||
data.unlisted = true;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ function makeEncryptedContent(
|
|||
password: string,
|
||||
opts: {
|
||||
unlisted?: boolean;
|
||||
stealth?: boolean;
|
||||
title?: string;
|
||||
tags?: string[];
|
||||
links?: string[];
|
||||
|
|
@ -38,7 +39,7 @@ function makeEncryptedContent(
|
|||
};
|
||||
|
||||
const vfile = new VFile("");
|
||||
vfile.data = {
|
||||
const data: Record<string, unknown> = {
|
||||
slug,
|
||||
relativePath: `${slug}.md`,
|
||||
encrypted: true,
|
||||
|
|
@ -51,7 +52,9 @@ function makeEncryptedContent(
|
|||
password,
|
||||
tags: opts.tags ?? [],
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
};
|
||||
if (opts.stealth) data.stealth = true;
|
||||
vfile.data = data;
|
||||
|
||||
return [tree, vfile];
|
||||
}
|
||||
|
|
@ -188,4 +191,29 @@ describe("EncryptedContentIndex emitter", () => {
|
|||
expect(shadow.version).toBe(SHADOW_INDEX_VERSION);
|
||||
expect(shadow.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips stealth pages from the shadow index", async () => {
|
||||
const content = [makeEncryptedContent("secret/stealth", "pw1", { stealth: true })];
|
||||
const shadow = await runEmitter(content);
|
||||
|
||||
expect(shadow.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes non-stealth pages alongside stealth pages (only stealth is skipped)", async () => {
|
||||
const content = [
|
||||
makeEncryptedContent("secret/revealable", "pw1"),
|
||||
makeEncryptedContent("secret/stealth", "pw1", { stealth: true }),
|
||||
makeEncryptedContent("secret/also-revealable", "pw1"),
|
||||
];
|
||||
const shadow = await runEmitter(content);
|
||||
|
||||
expect(shadow.entries).toHaveLength(2);
|
||||
|
||||
const decoded = shadow.entries.map(
|
||||
(e) => JSON.parse(decrypt(e.ciphertext, "pw1", e.iterations)) as ShadowContentIndexEntry,
|
||||
);
|
||||
const slugs = new Set(decoded.map((d) => d.slug));
|
||||
expect(slugs).toEqual(new Set(["secret/revealable", "secret/also-revealable"]));
|
||||
expect(slugs.has("secret/stealth")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -209,4 +209,72 @@ describe("EncryptedPages transformer", () => {
|
|||
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
|
||||
expect((vfile.data as Record<string, unknown>).unlisted).toBe(false);
|
||||
});
|
||||
|
||||
it("marks encrypted pages as stealth when frontmatter.stealth is true", async () => {
|
||||
const tree = createHastTree("Stealth content");
|
||||
const vfile = new VFile("");
|
||||
vfile.data = { frontmatter: { title: "Test", password: "pw", stealth: true } };
|
||||
|
||||
await runTransformer(tree, vfile);
|
||||
|
||||
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
|
||||
expect((vfile.data as Record<string, unknown>).stealth).toBe(true);
|
||||
});
|
||||
|
||||
it("stealth implies unlisted, even when frontmatter.unlisted is absent", async () => {
|
||||
const tree = createHastTree("Stealth content");
|
||||
const vfile = new VFile("");
|
||||
vfile.data = { frontmatter: { title: "Test", password: "pw", stealth: true } };
|
||||
|
||||
await runTransformer(tree, vfile);
|
||||
|
||||
expect((vfile.data as Record<string, unknown>).unlisted).toBe(true);
|
||||
});
|
||||
|
||||
it("stealth overrides explicit frontmatter.unlisted: false", async () => {
|
||||
const tree = createHastTree("Stealth content");
|
||||
const vfile = new VFile("");
|
||||
vfile.data = {
|
||||
frontmatter: { title: "Test", password: "pw", stealth: true, unlisted: false },
|
||||
};
|
||||
|
||||
await runTransformer(tree, vfile);
|
||||
|
||||
expect((vfile.data as Record<string, unknown>).stealth).toBe(true);
|
||||
expect((vfile.data as Record<string, unknown>).unlisted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mark non-encrypted pages as stealth even when frontmatter.stealth is true", async () => {
|
||||
const tree = createHastTree("Public content");
|
||||
const vfile = new VFile("");
|
||||
vfile.data = { frontmatter: { title: "Public Page", stealth: true } };
|
||||
|
||||
await runTransformer(tree, vfile);
|
||||
|
||||
expect((vfile.data as Record<string, unknown>).encrypted).toBeUndefined();
|
||||
expect((vfile.data as Record<string, unknown>).stealth).toBeUndefined();
|
||||
expect((vfile.data as Record<string, unknown>).unlisted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-boolean stealth values (string)", async () => {
|
||||
const tree = createHastTree("Secret");
|
||||
const vfile = new VFile("");
|
||||
vfile.data = { frontmatter: { title: "Test", password: "pw", stealth: "true" } };
|
||||
|
||||
await runTransformer(tree, vfile);
|
||||
|
||||
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
|
||||
expect((vfile.data as Record<string, unknown>).stealth).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mark encrypted pages as stealth when stealth is false", async () => {
|
||||
const tree = createHastTree("Secret");
|
||||
const vfile = new VFile("");
|
||||
vfile.data = { frontmatter: { title: "Test", password: "pw", stealth: false, unlisted: true } };
|
||||
|
||||
await runTransformer(tree, vfile);
|
||||
|
||||
expect((vfile.data as Record<string, unknown>).stealth).toBeUndefined();
|
||||
expect((vfile.data as Record<string, unknown>).unlisted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue