Skip to content

Commit 2162f61

Browse files
0.0.3: manual key sign-in — npub, hex pubkey, or nsec (PoC)
No signer extension? A 'Paste a key instead' link under the button reveals an input accepting: - npub1… (bech32-decoded in ~40 lines, checksum verified) - 64-hex pubkey - nsec1… — PoC, test keys only: reduced to its pubkey in memory via a lazy nostr-tools CDN import (only fetched when an nsec is actually pasted; the extension path stays dependency-free) All three feed the same resolve-and-redirect steps, now extracted from flow() into resolveAndGo(pubkey). The current flow never signs anything, so only the pubkey is used; the nsec path becomes the test vehicle for NIP-98 signing when 0.0.2 phase 2 lands. README: roadmap adds 0.0.3 (done), later items shift to 0.0.4-0.0.9, status section and version cross-references updated.
1 parent 4e1f533 commit 2162f61

3 files changed

Lines changed: 152 additions & 15 deletions

File tree

README.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ WAC-protected prompts for a real session via whatever app needs it.
3030
The `?webid=` handoff is plain text for now — unsigned, unverified.
3131
For the "magic SSO landing" promise that's enough — the signed
3232
handoff (NIP-98) is 0.0.2 phase 2, and the full authenticated
33-
session is 0.0.7 on the roadmap.
33+
session is 0.0.8 on the roadmap.
3434

3535
No username box. No IdP picker. No "what app are you logging in from?"
3636
question. Click → resolve → arrive.
@@ -98,7 +98,7 @@ https://sso.solid.social/?resolver=https://nostr.social&next=https://myapp.examp
9898
```
9999

100100
(Resolver memory in `localStorage` — so return visits skip straight
101-
to the right host — is 0.0.6 on the roadmap.)
101+
to the right host — is 0.0.7 on the roadmap.)
102102

103103
## Hosting
104104

@@ -113,11 +113,13 @@ Three sensible deployment targets:
113113

114114
## Status
115115

116-
**0.0.2 phase 1 — identity handoff**. The one-button UX works
117-
end-to-end against `solid.social` as the resolver, and the redirect
118-
now carries `?webid=did:nostr:<pubkey>` so the pod's auth widget can
119-
pick up the arriving identity (unsigned for now; phase 2 signs it
120-
with NIP-98). Resolution uses the JSS resolver chain shipped in
116+
**0.0.3 — manual key sign-in**. The one-button UX works end-to-end
117+
against `solid.social` as the resolver, the redirect carries
118+
`?webid=did:nostr:<pubkey>` so the pod's auth widget can pick up the
119+
arriving identity (unsigned for now; 0.0.2 phase 2 signs it with
120+
NIP-98), and users without a signer extension can paste an `npub`,
121+
hex pubkey, or — PoC, test keys only — an `nsec` instead.
122+
Resolution uses the JSS resolver chain shipped in
121123
[#408](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/408)
122124
through
123125
[#418](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/418).
@@ -132,22 +134,27 @@ adding one capability).
132134
`?webid=did:nostr:<pubkey>` to the redirect (plain text). ✅
133135
Phase 2: sign it (NIP-98 in the URL fragment) so the pod can
134136
verify the arriving identity.
135-
- **0.0.3** — Step-by-step coaching UI: checklist that ticks each
137+
- **0.0.3** — Manual key sign-in: no signer extension needed.
138+
Paste an `npub`, 64-hex pubkey, or (PoC, test keys only) an
139+
`nsec` — reduced to its pubkey in memory — and the same
140+
resolve-and-redirect flow runs. ✅
141+
- **0.0.4** — Step-by-step coaching UI: checklist that ticks each
136142
step (extension detected, pubkey resolved, DID doc found, WebID
137143
extracted, redirect) instead of a one-shot status line.
138-
- **0.0.4** — Multi-resolver fallback: try a configurable list of
144+
- **0.0.5** — Multi-resolver fallback: try a configurable list of
139145
resolvers in order (`solid.social`, then `nostr.social` as the
140146
did-nostr.com source fallback) before giving up.
141-
- **0.0.5** — Direct Nostr-relay resolution. Sits on top of
147+
- **0.0.6** — Direct Nostr-relay resolution. Sits on top of
142148
[JSS#414](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/414).
143149
Removes the hardcoded-resolver dependency entirely.
144-
- **0.0.6** — User-preferred resolver memory: first successful
150+
- **0.0.7** — User-preferred resolver memory: first successful
145151
resolution is cached in `localStorage` so subsequent visits skip
146152
straight to the right host.
147-
- **0.0.7** — Authenticated session: optional NIP-98 → DPoP
153+
- **0.0.8** — Authenticated session: optional NIP-98 → DPoP
148154
exchange against the IdP, so the user lands at their pod
149-
already signed in (not just navigated there).
150-
- **0.0.8** — Passkey-only fallback. Sign in without a Nostr
155+
already signed in (not just navigated there). The pasted-nsec
156+
path doubles as the test vehicle for NIP-98 signing.
157+
- **0.0.9** — Passkey-only fallback. Sign in without a Nostr
151158
extension via WebAuthn.
152159

153160
## Why a separate repo?

index.html

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,42 @@
106106
animation: spin 0.7s linear infinite;
107107
}
108108
@keyframes spin { to { transform: rotate(360deg); } }
109+
.linkish {
110+
background: none; border: none; padding: 0;
111+
color: var(--muted); font-size: 0.85rem;
112+
cursor: pointer;
113+
text-decoration: underline; text-underline-offset: 3px;
114+
}
115+
.linkish:hover { color: var(--accent); }
116+
.linkish:focus-visible { outline: 2px solid #c4b5fd; outline-offset: 2px; border-radius: 4px; }
117+
#manual {
118+
width: 100%; margin: 0;
119+
display: flex; flex-wrap: wrap; gap: 0.6rem;
120+
justify-content: center;
121+
}
122+
#manual[hidden] { display: none; }
123+
#manual input {
124+
flex: 1 1 14rem;
125+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
126+
font-size: 0.9rem;
127+
padding: 0.65rem 0.9rem;
128+
border: 1px solid #d0d7de; border-radius: 10px;
129+
color: var(--fg); min-width: 0;
130+
}
131+
#manual input:focus-visible { outline: 2px solid #c4b5fd; border-color: var(--accent); }
132+
#manual button {
133+
font-size: 0.95rem; font-weight: 600;
134+
padding: 0.65rem 1.4rem;
135+
background: var(--accent); color: white;
136+
border: none; border-radius: 10px; cursor: pointer;
137+
}
138+
#manual button:hover { background: var(--accent-hover); }
139+
#manual button:disabled { opacity: 0.6; cursor: progress; }
140+
#manual button:focus-visible { outline: 2px solid #c4b5fd; outline-offset: 2px; }
141+
#manual .warn {
142+
flex-basis: 100%; margin: 0;
143+
font-size: 0.8rem; color: #9a6700;
144+
}
109145
#status {
110146
color: var(--muted); font-size: 0.95rem;
111147
min-height: 4rem; max-width: 100%;
@@ -143,6 +179,14 @@ <h1>Sign in to Solid</h1>
143179
<p class="tagline">One click. We resolve your Nostr / WebID. You land at your pod.</p>
144180
</header>
145181
<button id="signin">Sign in</button>
182+
<button id="alt-toggle" type="button" class="linkish">No signer? Paste a key instead</button>
183+
<form id="manual" hidden>
184+
<input id="manual-key" autocomplete="off" spellcheck="false"
185+
placeholder="npub1…, nsec1…, or 64-hex pubkey"
186+
aria-label="Nostr public or private key">
187+
<button type="submit">Go</button>
188+
<p class="warn">Proof of concept — paste test keys only. Real keys belong in a signer.</p>
189+
</form>
146190
<div id="status" aria-live="polite"></div>
147191
</div>
148192
<footer>

login.js

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
// → fetch <resolver>/.well-known/did/nostr/<pubkey>.json
77
// → read alsoKnownAs[0] → WebID
88
// → redirect to that WebID's pod root
9+
// or, without a signer: paste npub / hex pubkey / nsec (PoC,
10+
// test keys only — nsec is reduced to its pubkey in memory)
11+
// and the same resolve-and-redirect steps run.
912
//
1013
// What this page does NOT do (yet):
1114
// Establish an authenticated session AT the pod. The SSO page is
@@ -101,8 +104,62 @@ function waitForSigner(ms = 1500) {
101104
});
102105
}
103106

107+
// ---- Manual key entry (PoC) ----
108+
// The current flow only ever needs a *public* key. nsec is accepted
109+
// so the "sign in with your private key" UX can be exercised with
110+
// throwaway keys before phase 2 (NIP-98 signing) lands. The key is
111+
// decoded in memory, reduced to its pubkey, and never stored.
112+
113+
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
114+
115+
function polymodStep(pre) {
116+
const b = pre >> 25;
117+
let chk = (pre & 0x1ffffff) << 5;
118+
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
119+
for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i];
120+
return chk;
121+
}
122+
123+
function bech32Decode(s) {
124+
const pos = s.lastIndexOf('1');
125+
if (pos < 1 || pos + 7 > s.length) return null;
126+
const hrp = s.slice(0, pos);
127+
const data = [...s.slice(pos + 1)].map((c) => BECH32_CHARSET.indexOf(c));
128+
if (data.includes(-1)) return null;
129+
let chk = 1;
130+
for (const c of hrp) chk = polymodStep(chk) ^ (c.charCodeAt(0) >> 5);
131+
chk = polymodStep(chk);
132+
for (const c of hrp) chk = polymodStep(chk) ^ (c.charCodeAt(0) & 31);
133+
for (const d of data) chk = polymodStep(chk) ^ d;
134+
if (chk !== 1) return null;
135+
let acc = 0, bits = 0;
136+
const out = [];
137+
for (const v of data.slice(0, -6)) {
138+
acc = (acc << 5) | v; bits += 5;
139+
if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); }
140+
}
141+
return { hrp, bytes: new Uint8Array(out) };
142+
}
143+
144+
const toHex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
145+
146+
async function pubkeyFromInput(raw) {
147+
const s = raw.trim().toLowerCase();
148+
if (!s) throw new Error('Enter a key first.');
149+
if (/^[0-9a-f]{64}$/.test(s)) return s;
150+
const dec = bech32Decode(s);
151+
if (!dec || dec.bytes.length !== 32) {
152+
throw new Error('Couldn’t parse that key — expected npub1…, nsec1…, or 64 hex characters.');
153+
}
154+
if (dec.hrp === 'npub') return toHex(dec.bytes);
155+
if (dec.hrp === 'nsec') {
156+
const { getPublicKey } = await import('https://cdn.jsdelivr.net/npm/nostr-tools@2/+esm');
157+
return getPublicKey(dec.bytes);
158+
}
159+
throw new Error(`Unsupported key type “${dec.hrp}” — expected npub or nsec.`);
160+
}
161+
104162
async function flow() {
105-
const { resolver, next } = readConfig();
106163
setStatus('Reading your Nostr identity…');
107164

108165
// Step 1 — signer extension present?
@@ -135,6 +192,12 @@ async function flow() {
135192
return false;
136193
}
137194

195+
return resolveAndGo(pubkey);
196+
}
197+
198+
async function resolveAndGo(pubkey) {
199+
const { resolver, next } = readConfig();
200+
138201
// Step 3 — resolve via the well-known DID-doc endpoint
139202
setStatus(`Resolving did:nostr:${pubkey.slice(0, 8)}… via ${resolver}`);
140203
let didDoc;
@@ -220,6 +283,29 @@ function wire() {
220283

221284
button.addEventListener('click', run);
222285

286+
const toggle = document.querySelector('#alt-toggle');
287+
const form = document.querySelector('#manual');
288+
if (toggle && form) {
289+
toggle.addEventListener('click', () => {
290+
form.hidden = !form.hidden;
291+
if (!form.hidden) form.querySelector('input').focus();
292+
});
293+
form.addEventListener('submit', async (e) => {
294+
e.preventDefault();
295+
const input = form.querySelector('input');
296+
const go = form.querySelector('button');
297+
input.disabled = true; go.disabled = true;
298+
try {
299+
const pubkey = await pubkeyFromInput(input.value);
300+
const ok = await resolveAndGo(pubkey);
301+
if (!ok) { input.disabled = false; go.disabled = false; }
302+
} catch (err) {
303+
setStatus(err.message, 'error');
304+
input.disabled = false; go.disabled = false;
305+
}
306+
});
307+
}
308+
223309
if (new URLSearchParams(location.search).has('resolver')) {
224310
// Arriving via a "Try the fallback resolver" link (or any explicit
225311
// ?resolver=) carries a click's worth of intent — resume the flow

0 commit comments

Comments
 (0)