From 9e8d235fdf026838a7896688ae841440d313b744 Mon Sep 17 00:00:00 2001
From: Ian Clarke
Date: Mon, 28 Sep 2026 09:26:07 -0500
Subject: [PATCH 1/4] feat(ghostkey): make the vault findable, and keep it off
try.freenet.org
The Ghost Key vault was linked only from low on /ghostkey/ and from the
donation success page, both as raw localhost links that dead-end for
anyone without a running peer.
- /ghostkey/ hero: "Already have one? Open your vault".
- /ghostkey/ vault button and the Apps page entry now go through
/open, so a visitor without a peer gets "Get Freenet" instead of a
dead link. The import button stays a direct localhost link: its
fragment carries the signing key.
- /open gains a LOCAL_ONLY list. For the vault it replaces "Use in your
browser" with a note: try.freenet.org is for trying Freenet out, and
a Ghost Key kept there lives on a peer we host. /ghostkey/ says the
same. Tested, including a hashchange back to an ordinary id.
- Success page no longer claims nothing else links to the vault.
- Correct the stale IPv6-only comment in donation-success.js
(freenet-core#4332 serves both loopbacks).
Claude-Session: https://claude.ai/code/session_011kgp91jp3UiWjo5eHAQfyq
---
hugo-site/content/apps/_index.md | 2 +-
hugo-site/content/build/manual/share-links.md | 4 +-
hugo-site/content/ghostkey/_index.md | 15 ++++--
hugo-site/content/ghostkey/success/_index.md | 6 +--
hugo-site/static/js/donation-success.js | 7 +--
hugo-site/tests/open-link-vectors.test.mjs | 52 +++++++++++++++++--
.../freenet/layouts/shortcodes/open-link.html | 23 +++++++-
7 files changed, 90 insertions(+), 19 deletions(-)
diff --git a/hugo-site/content/apps/_index.md b/hugo-site/content/apps/_index.md
index 5b010d12..a307ee76 100644
--- a/hugo-site/content/apps/_index.md
+++ b/hugo-site/content/apps/_index.md
@@ -91,7 +91,7 @@ Anonymous, Sybil-resistant identity certificates. Donate a small amount to
mint a key; apps can verify the certificate without learning who you are. Used
across Freenet apps as a spam- and abuse-resistance primitive.
-→ [Get a Ghost Key](/ghostkey/) · [github.com/freenet/ghostkeys](https://github.com/freenet/ghostkeys)
+→ [Get a Ghost Key](/ghostkey/) · [Open your vault](/open/#DLog47hEsrtuGT4N5XCeMBG45m4n1aWM89tBZXue2E1N/) · [github.com/freenet/ghostkeys](https://github.com/freenet/ghostkeys)
### freenet-scaffold {#freenet-scaffold}
diff --git a/hugo-site/content/build/manual/share-links.md b/hugo-site/content/build/manual/share-links.md
index d9da9ace..c77bc7e8 100644
--- a/hugo-site/content/build/manual/share-links.md
+++ b/hugo-site/content/build/manual/share-links.md
@@ -50,7 +50,9 @@ https://freenet.org/open#6FzSeAUKcqJrveKyU8RJgGKc5jRB1Z2juvxXtwTA4Em9/#store=Ab3
visitor's own local peer (`http://127.0.0.1:7509/v1/contract/web//...`), for anyone
who already has Freenet installed and running.
- **Use in your browser** -- the same target on `try.freenet.org`, a peer we host, for anyone who
- wants to look without installing anything.
+ wants to look without installing anything. Apps that hold keys a visitor should keep on their own
+ peer, currently just the Ghost Key vault, get a note saying so instead of this button; the list is
+ `LOCAL_ONLY` in `open-link.html`.
- **Open in Freenet** -- `freenet:/...`, which the visitor's own Freenet opens
on their local peer. The handler ([freenet-core#5726](https://github.com/freenet/freenet-core/issues/5726))
ships in the first release after 0.2.139, so until peers have updated this button is styled and
diff --git a/hugo-site/content/ghostkey/_index.md b/hugo-site/content/ghostkey/_index.md
index 46172090..034418d4 100644
--- a/hugo-site/content/ghostkey/_index.md
+++ b/hugo-site/content/ghostkey/_index.md
@@ -14,6 +14,7 @@ hold a scarce, donation-backed identity, without ever learning who you are.
## The short version
@@ -206,15 +207,19 @@ new node later.
### Opening your vault
-If you have a Freenet node running on this computer, your Ghost Keys are here:
+Your Ghost Keys live in the vault on your own Freenet peer:
-That address is your own machine, not a website — the vault runs inside your node, and the page is
-served locally. Bookmark it if you use Ghost Keys regularly.
+The vault runs inside your peer, not on a website, so its address is on your own machine:
+[`http://127.0.0.1:7509/v1/contract/web/DLog47hEsrtuGT4N5XCeMBG45m4n1aWM89tBZXue2E1N/`](http://127.0.0.1:7509/v1/contract/web/DLog47hEsrtuGT4N5XCeMBG45m4n1aWM89tBZXue2E1N/).
+Bookmark it if you use Ghost Keys regularly.
+
+Keep your Ghost Keys on your own peer rather than on [try.freenet.org](/try/). That hosted peer is
+for trying Freenet out, and a key kept there lives on a machine we run instead of yours.
For developers, everything is open source:
diff --git a/hugo-site/content/ghostkey/success/_index.md b/hugo-site/content/ghostkey/success/_index.md
index 9d8310d6..98bd522c 100644
--- a/hugo-site/content/ghostkey/success/_index.md
+++ b/hugo-site/content/ghostkey/success/_index.md
@@ -30,8 +30,8 @@ from one at any time.
**Your vault lives at**
[localhost:7509/v1/contract/web/DLog47hEs…](http://localhost:7509/v1/contract/web/DLog47hEsrtuGT4N5XCeMBG45m4n1aWM89tBZXue2E1N/),
-on this computer rather than on the web. Importing opens it for you, but the
-address is worth bookmarking — it is how you reach your keys later, and nothing
-else links to it.
+on this computer rather than on the web. Importing opens it for you. Bookmark
+the address, since it is how you reach your keys later, or find it again from
+the [Ghost Key page](/ghostkey/#opening-your-vault).
{{< bulma-button href="/ghostkey/" color="#339966" >}}Ghost Key FAQ{{< /bulma-button >}}
diff --git a/hugo-site/static/js/donation-success.js b/hugo-site/static/js/donation-success.js
index 71cf1b22..58b72674 100644
--- a/hugo-site/static/js/donation-success.js
+++ b/hugo-site/static/js/donation-success.js
@@ -315,9 +315,10 @@ function displayCertificate(armoredCertificate, armoredSigningKey) {
const certB64 = urlSafeBase64(armoredCertificate);
const skB64 = urlSafeBase64(armoredSigningKey);
const contractId = 'DLog47hEsrtuGT4N5XCeMBG45m4n1aWM89tBZXue2E1N';
- // Use localhost rather than the literal 127.0.0.1: the node binds its
- // API on the IPv6 loopback by default, so on Windows "localhost" (::1)
- // is reachable while the literal IPv4 address is refused.
+ // localhost rather than the literal 127.0.0.1 dates from when the node
+ // served its API on the IPv6 loopback only, so Windows refused the
+ // IPv4 literal. Nodes serve both since freenet-core#4332; localhost
+ // still reaches peers older than that.
// If the donor arrived from an app, hand the vault the way back so it
// can offer a one-click return once the key has actually landed.
// Re-validated here because it has been through a Stripe redirect;
diff --git a/hugo-site/tests/open-link-vectors.test.mjs b/hugo-site/tests/open-link-vectors.test.mjs
index 781f943b..f7a6c0f2 100644
--- a/hugo-site/tests/open-link-vectors.test.mjs
+++ b/hugo-site/tests/open-link-vectors.test.mjs
@@ -31,16 +31,23 @@ function runPage(hash) {
getElementById: el,
addEventListener: (ev, fn) => (listeners[ev] = fn),
};
+ const windowListeners = {};
const window = {
location: { hash },
- addEventListener: () => {},
+ addEventListener: (ev, fn) => (windowListeners[ev] = fn),
};
vm.runInNewContext(match[1], { document, window });
listeners.DOMContentLoaded();
- const shown = ["open-link-missing", "open-link-invalid", "open-link-valid"].find(
- (id) => el(id).style.display === "",
- );
- return { shown, el };
+ const shown = () =>
+ ["open-link-missing", "open-link-invalid", "open-link-valid"].find(
+ (id) => el(id).style.display === "",
+ );
+ const navigate = (newHash) => {
+ window.location.hash = newHash;
+ windowListeners.hashchange();
+ return shown();
+ };
+ return { shown: shown(), el, navigate };
}
const { vectors } = JSON.parse(
@@ -70,6 +77,41 @@ for (const v of vectors) {
console.error(`FAIL ${JSON.stringify(v.raw.slice(0, 80))} (${v.note}): ${problem}`);
}
}
+
+// Local-only apps (the Ghost Key vault): no try.freenet.org button, a note
+// saying why instead, and the local button unaffected. Driven through a
+// hashchange to an ordinary id too, since both states must be reset.
+const VAULT = "DLog47hEsrtuGT4N5XCeMBG45m4n1aWM89tBZXue2E1N";
+const OTHER = "6FzSeAUKcqJrveKyU8RJgGKc5jRB1Z2juvxXtwTA4Em9";
+{
+ const check = (cond, what) => {
+ if (!cond) {
+ failures++;
+ console.error(`FAIL local-only: ${what}`);
+ }
+ };
+ const tryHidden = (el) => el("open-link-try-option").style.display === "none";
+ const noteShown = (el) =>
+ el("open-link-local-only").style.display === "" &&
+ el("open-link-local-only").textContent.length > 0;
+
+ const { shown, el, navigate } = runPage("#" + VAULT + "/");
+ check(shown === "open-link-valid", `vault link showed ${shown}`);
+ check(tryHidden(el), "try option visible for the vault");
+ check(noteShown(el), "no local-only note for the vault");
+ check(
+ el("open-link-local").href === `http://127.0.0.1:7509/v1/contract/web/${VAULT}/`,
+ `vault local button ${el("open-link-local").href}`,
+ );
+
+ check(navigate("#" + OTHER + "/") === "open-link-valid", "other id not valid");
+ check(!tryHidden(el), "try option still hidden after leaving the vault");
+ check(el("open-link-local-only").style.display === "none", "note still shown after leaving the vault");
+
+ const fresh = runPage("#" + OTHER + "/");
+ check(!tryHidden(fresh.el), "try option hidden for an ordinary id");
+}
+
console.log(`${vectors.length} vectors, ${failures} failures`);
if (vectors.length < 40) {
console.error("vector file looks truncated");
diff --git a/hugo-site/themes/freenet/layouts/shortcodes/open-link.html b/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
index 335e7956..5b373615 100644
--- a/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
+++ b/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
@@ -107,12 +107,13 @@ Open this link
Use this if Freenet is already installed and running on this computer.
-
+
+
Open in Freenet
@@ -147,6 +148,18 @@
Open this link
return map;
})();
var CONTRACT_KEY_BYTES = 32;
+
+ // Apps that hold keys a visitor should keep on their own peer. For these
+ // the "Use in your browser" button is replaced by the reason:
+ // try.freenet.org is for trying Freenet out, and anything stored there
+ // lives on a peer we host. Keyed by the app's web container id, so a
+ // re-key of the app must update its entry here too.
+ var LOCAL_ONLY = Object.create(null);
+ // Ghost Key vault (freenet/ghostkeys, published-contract/contract-id.txt).
+ LOCAL_ONLY["DLog47hEsrtuGT4N5XCeMBG45m4n1aWM89tBZXue2E1N"] =
+ "This is the Ghost Key vault, so open it on your own peer. " +
+ "try.freenet.org is for trying Freenet out, and a Ghost Key kept there lives on a " +
+ "peer we host instead of your computer.";
// A genuine 32-byte id encodes to at most 44 base58 characters; 64 is a
// generous ceiling above that. Rejecting anything longer before decoding
// keeps a pasted wall of text from making this page do O(n^2) bignum
@@ -344,6 +357,14 @@
Open this link
document.getElementById("open-link-try").href =
"https://try.freenet.org/v1/contract/web/" + contractId + parts.rest;
+ // Set both ways every time: hashchange can move between a local-only
+ // app and any other.
+ var localOnly = LOCAL_ONLY[contractId];
+ document.getElementById("open-link-try-option").style.display = localOnly ? "none" : "";
+ var localOnlyNote = document.getElementById("open-link-local-only");
+ localOnlyNote.textContent = localOnly || "";
+ localOnlyNote.style.display = localOnly ? "" : "none";
+
show("open-link-valid");
}
From 08ce421420afb50a5d49c72bb70b09dc737ade7d Mon Sep 17 00:00:00 2001
From: Ian Clarke
Date: Mon, 28 Sep 2026 09:27:47 -0500
Subject: [PATCH 2/4] fix(open): say plainly that the freenet: handler is not
released yet
"Needs a Freenet release newer than 0.2.139" read as "0.2.139 or later":
a 0.2.139 user clicked "Open in Freenet" and got "No apps available".
The handler (freenet-core#5753) merged after 0.2.139 was cut, so no
release has it yet.
Claude-Session: https://claude.ai/code/session_011kgp91jp3UiWjo5eHAQfyq
---
hugo-site/themes/freenet/layouts/shortcodes/open-link.html | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/hugo-site/themes/freenet/layouts/shortcodes/open-link.html b/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
index 5b373615..7a747cc5 100644
--- a/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
+++ b/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
@@ -93,7 +93,8 @@ Open this link
freenet-core#5753) has shipped in a release and peers have
auto-updated to it -- until then, "Open in Freenet" below does
nothing for most visitors. Swap the `is-primary` class and the
- button order back to lead with "Open in Freenet" after that. -->
+ button order back to lead with "Open in Freenet" after that, and
+ replace its "not in a Freenet release yet" note. -->
Open this link
Open in Freenet
- Needs a Freenet release newer than 0.2.139 (it registers freenet: links). If nothing
- happens, try the options above instead.
+ Not in a Freenet release yet: it arrives in the next one after 0.2.139, which teaches
+ your computer to open freenet: links. Until you have it, use the options above.
From 8002c0e9becc49ac55934331288819fdf7be707e Mon Sep 17 00:00:00 2001
From: Ian Clarke
Date: Mon, 28 Sep 2026 09:32:24 -0500
Subject: [PATCH 3/4] fix(open): hide "Open in Freenet" until a release has the
handler
No Freenet release registers freenet: links yet (freenet-core#5753 merged
after 0.2.139 was cut), so the button only produced "no app can open this
link". Hidden with display:none rather than removed, so the page JS still
fills in its href and the shared-vector test still checks it; the comment
above it says how to re-enable. Its note is reworded to be correct on the
day it comes back.
check-links: /open's fragment is a share link its JS reads, not an anchor,
so links to /open/#/ were reported as dead anchors. Exempt that one
page (it must still exist), with self-test cases proving /open is exempt
and other pages are not.
Claude-Session: https://claude.ai/code/session_011kgp91jp3UiWjo5eHAQfyq
---
hugo-site/content/build/manual/share-links.md | 10 +++++-----
.../freenet/layouts/shortcodes/open-link.html | 16 +++++++++++-----
scripts/check-links.py | 12 ++++++++++++
3 files changed, 28 insertions(+), 10 deletions(-)
diff --git a/hugo-site/content/build/manual/share-links.md b/hugo-site/content/build/manual/share-links.md
index c77bc7e8..bcc54b35 100644
--- a/hugo-site/content/build/manual/share-links.md
+++ b/hugo-site/content/build/manual/share-links.md
@@ -44,7 +44,7 @@ https://freenet.org/open#6FzSeAUKcqJrveKyU8RJgGKc5jRB1Z2juvxXtwTA4Em9/#store=Ab3
## What the page does
-`/open` validates the contract id and shows four buttons, in this order:
+`/open` validates the contract id and shows these buttons, in this order:
- **Open on this computer** (currently the highlighted, primary button) -- the target on the
visitor's own local peer (`http://127.0.0.1:7509/v1/contract/web//...`), for anyone
@@ -55,10 +55,10 @@ https://freenet.org/open#6FzSeAUKcqJrveKyU8RJgGKc5jRB1Z2juvxXtwTA4Em9/#store=Ab3
`LOCAL_ONLY` in `open-link.html`.
- **Open in Freenet** -- `freenet:/...`, which the visitor's own Freenet opens
on their local peer. The handler ([freenet-core#5726](https://github.com/freenet/freenet-core/issues/5726))
- ships in the first release after 0.2.139, so until peers have updated this button is styled and
- ordered as a secondary option. The link has no `//`: in `freenet://` the
- case-sensitive contract id would be the URL's host, which some desktops lowercase before the
- handler sees it. (The handler accepts both forms.)
+ ships in the first release after 0.2.139, so this button is hidden until that release is out,
+ and then shown as a secondary option until peers have updated. The link has no `//`: in
+ `freenet://` the case-sensitive contract id would be the URL's host, which some
+ desktops lowercase before the handler sees it. (The handler accepts both forms.)
- **Get Freenet** -- the [install guide](/quickstart/).
An invalid or truncated fragment shows a "this link looks broken" message instead of guessing at
diff --git a/hugo-site/themes/freenet/layouts/shortcodes/open-link.html b/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
index 7a747cc5..1e96a850 100644
--- a/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
+++ b/hugo-site/themes/freenet/layouts/shortcodes/open-link.html
@@ -93,8 +93,7 @@ Open this link
freenet-core#5753) has shipped in a release and peers have
auto-updated to it -- until then, "Open in Freenet" below does
nothing for most visitors. Swap the `is-primary` class and the
- button order back to lead with "Open in Freenet" after that, and
- replace its "not in a Freenet release yet" note. -->
+ button order back to lead with "Open in Freenet" after that. -->
-
+
+
Open in Freenet
- Not in a Freenet release yet: it arrives in the next one after 0.2.139, which teaches
- your computer to open freenet: links. Until you have it, use the options above.
+ Opens it in the Freenet installed on this computer. If nothing happens, your Freenet may
+ be older than this feature: try the options above instead.
diff --git a/scripts/check-links.py b/scripts/check-links.py
index 660b4866..a18df67d 100644
--- a/scripts/check-links.py
+++ b/scripts/check-links.py
@@ -52,6 +52,11 @@
# element. https://html.spec.whatwg.org/multipage/browsing-the-web.html#scrolling-to-a-fragment
ALWAYS_VALID_FRAGMENTS = {"top"}
+# Pages whose fragment is data their own JavaScript reads, not an anchor:
+# /open's is a share link (/open#
/...). The page itself must
+# still exist.
+FRAGMENT_IS_DATA_PAGES = {"/open/index.html"}
+
MAX_REDIRECTS = 5
# — only meaningful in ,
@@ -254,6 +259,8 @@ def check(root):
if not fragment or fragment.lower() in ALWAYS_VALID_FRAGMENTS:
continue
+ if target_key in FRAGMENT_IS_DATA_PAGES:
+ continue
# Check the page's own ids before following any redirect: a real
# page can carry a meta refresh and still be the fragment's home.
if fragment in pages[target_key].ids:
@@ -343,6 +350,9 @@ def write(path, body):
good: fragment through a percent-encoded path
good: percent-encoded fragment too
good: alias whose refresh URL is encoded
+ good: /open reads its fragment as data
+ good: same, without the slash
+ BAD: only /open is exempt
BAD: must not escape the output tree
BAD: host taken from sitemap.xml
@@ -355,6 +365,7 @@ def write(path, body):
"",
)
write("about/index.html", "hi
")
+ write("open/index.html", "reads location.hash
")
write("old-faq/index.html", '')
write("loop-a/index.html", '')
write("loop-b/index.html", '')
@@ -422,6 +433,7 @@ def write(path, body):
"/dangling-alias/#anything",
"/body-refresh/#what-is-freenet",
"/dup-attr/",
+ "/about/#6FzSeAUKcqJrveKyU8RJgGKc5jRB1Z2juvxXtwTA4Em9/",
"HTTPS://FREENET.ORG/nope/",
"https://freenet.org:443/nope/",
"/%2e%2e/%2e%2e/etc/hostname",
From 81bdb2d23c1b7165404c508b60ce3e5c8d407edb Mon Sep 17 00:00:00 2001
From: Ian Clarke
Date: Mon, 28 Sep 2026 09:35:49 -0500
Subject: [PATCH 4/4] fix(check-links): exempt /open's fragment through a
redirect too
Review of #189: the exemption was checked only on the linked page, so an
alias that redirects to /open would have had its share-link fragment
flagged as a dead anchor. Check the redirect target as well; self-test
case added (fails with the check removed).
Claude-Session: https://claude.ai/code/session_011kgp91jp3UiWjo5eHAQfyq
---
scripts/check-links.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/scripts/check-links.py b/scripts/check-links.py
index a18df67d..5cd9c867 100644
--- a/scripts/check-links.py
+++ b/scripts/check-links.py
@@ -269,6 +269,8 @@ def check(root):
if resolved is None:
if reason is not None:
broken.append((source_key, reference, reason))
+ elif resolved in FRAGMENT_IS_DATA_PAGES:
+ continue # an alias of /open still hands the fragment to its JS
elif fragment not in pages[resolved].ids:
broken.append((source_key, reference, "no #%s on the target page" % fragment))
return len(pages), broken
@@ -352,6 +354,7 @@ def write(path, body):
good: alias whose refresh URL is encoded
good: /open reads its fragment as data
good: same, without the slash
+ good: through an alias of /open
BAD: only /open is exempt
BAD: must not escape the output tree
@@ -366,6 +369,7 @@ def write(path, body):
)
write("about/index.html", "hi
")
write("open/index.html", "reads location.hash
")
+ write("old-open/index.html", '')
write("old-faq/index.html", '')
write("loop-a/index.html", '')
write("loop-b/index.html", '')