local pin (#373)

This commit is contained in:
BlowaterNostr 2024-01-19 15:21:43 +08:00 committed by GitHub
parent 0d43a4e597
commit 65f98f4236
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 317 additions and 87 deletions

View File

@ -7,6 +7,7 @@ import {
NostrKind,
UnsignedNostrEvent,
} from "../../libs/nostr.ts/nostr.ts";
import { LocalPrivateKeyController } from "./signIn.tsx";
type NIP07 = {
getPublicKey(): Promise<string>;
@ -115,16 +116,12 @@ export class Nip7ExtensionContext implements NostrAccountContext {
};
}
export function GetLocalStorageAccountContext() {
const loginPrivateKey = localStorage.getItem("MPK");
if (loginPrivateKey) {
const priKey = PrivateKey.FromHex(loginPrivateKey);
if (!(priKey instanceof Error)) {
return InMemoryAccountContext.New(priKey);
}
console.error("the stored MPK is not a valid private, removing it");
localStorage.removeItem("MPK");
export async function GetLocalStorageAccountContext(pin: string) {
const priKey = await LocalPrivateKeyController.getKey(pin);
if (priKey instanceof Error) {
return priKey;
} else if (priKey == undefined) {
return undefined;
}
return undefined;
return InMemoryAccountContext.New(priKey);
}

View File

@ -24,7 +24,14 @@ import { EventSyncer } from "./event_syncer.ts";
import { RelayConfig } from "./relay-config.ts";
import { ProfileGetter } from "./search.tsx";
import { Setting } from "./setting.tsx";
import { getCurrentSignInCtx, setSignInState, SignIn } from "./signIn.tsx";
import {
forgot_pin,
getCurrentSignInCtx,
getPinFromUser,
getSignInState,
setSignInState,
SignIn,
} from "./signIn.tsx";
import { SecondaryBackgroundColor } from "./style/colors.ts";
import { LamportTime } from "../time.ts";
import { InstallPrompt, NavBar } from "./nav.tsx";
@ -56,23 +63,37 @@ export async function Start(database: DexieDatabase) {
}
})();
const ctx = await getCurrentSignInCtx();
if (ctx instanceof Error) {
console.error(ctx);
} else if (ctx) {
const otherConfig = await OtherConfig.FromLocalStorage(ctx, newNostrEventChannel, lamport);
const app = await App.Start({
database: dbView,
model,
ctx,
eventBus,
pool,
popOverInputChan,
otherConfig,
lamport,
installPrompt,
});
model.app = app;
{
let err: Error | undefined;
for (;;) {
if (getSignInState() === "none") {
break;
}
const pin = await getPinFromUser(err);
if (pin == forgot_pin) {
break;
}
const ctx = await getCurrentSignInCtx(pin);
if (ctx instanceof Error) {
err = ctx;
continue;
} else if (ctx) {
const otherConfig = await OtherConfig.FromLocalStorage(ctx, newNostrEventChannel, lamport);
const app = await App.Start({
database: dbView,
model,
ctx,
eventBus,
pool,
popOverInputChan,
otherConfig,
lamport,
installPrompt,
});
model.app = app;
break;
}
}
}
/* first render */ render(

View File

@ -1,7 +1,6 @@
/** @jsx h */
import { h } from "https://esm.sh/preact@10.17.1";
import { render } from "https://esm.sh/preact@10.17.1";
import { PrivateKey } from "../../libs/nostr.ts/key.ts";
import { testEventBus } from "./_setup.test.ts";
import { SignIn } from "./signIn.tsx";

View File

@ -1,5 +1,14 @@
/** @jsx h */
import { Component, h } from "https://esm.sh/preact@10.17.1";
import {
Attributes,
Component,
ComponentChild,
ComponentChildren,
createRef,
h,
Ref,
render,
} from "https://esm.sh/preact@10.17.1";
import { GetLocalStorageAccountContext, Nip7ExtensionContext } from "./account-context.ts";
import { ButtonClass, CenterClass, LinearGradientsClass, NoOutlineClass } from "./components/tw.ts";
import KeyView from "./key-view.tsx";
@ -40,7 +49,7 @@ export function setSignInState(state: SignInState) {
////////////////////////
// Check Login Status //
////////////////////////
export async function getCurrentSignInCtx() {
export async function getCurrentSignInCtx(pin: string) {
if (getSignInState() === "nip07") {
const nip07Ctx = await Nip7ExtensionContext.New();
if (nip07Ctx instanceof Error) {
@ -52,9 +61,9 @@ export async function getCurrentSignInCtx() {
return nip07Ctx;
}
if (getSignInState() === "local") {
const ctx = GetLocalStorageAccountContext();
const ctx = await GetLocalStorageAccountContext(pin);
if (ctx instanceof Error) {
throw ctx;
return ctx;
}
if (ctx === undefined) {
console.log("GetLocalStorageAccountContext is undefined");
@ -70,22 +79,24 @@ type Props = {
};
type State = {
state: "newAccount" | "enterPrivateKey";
step: "newAccount" | "enterPrivateKey" | "enter local pin" | "confirm local pin";
localPin: string;
privateKey: PrivateKey;
privateKeyError: string;
nip07Error: string;
};
export class SignIn extends Component<Props, State> {
localPinInput = createRef<HTMLInputElement>();
styles = {
container:
`h-screen w-screen bg-[${PrimaryBackgroundColor}] flex items-center justify-center p-4 overflow-y-auto`,
container: `h-screen w-screen bg-[${PrimaryBackgroundColor}] ` +
`flex flex-col items-center justify-center p-4 overflow-y-auto`,
form: `w-[30rem] flex flex-col h-full py-8`,
logo: `w-32 h-32 mx-auto`,
title: `text-[${PrimaryTextColor}] text-center text-4xl`,
subTitle: `text-[${HintTextColor}] text-center`,
input: `w-full px-4 py-2 focus-visible:outline-none rounded-lg mt-8`,
hint: `text-[${HintTextColor}] text-sm mt-2`,
block: `flex-1 desktop:hidden`,
signInButton:
`w-full mt-4 ${ButtonClass} ${LinearGradientsClass} hover:bg-gradient-to-l mobile:rounded-full font-bold`,
cancelButton:
@ -104,13 +115,8 @@ export class SignIn extends Component<Props, State> {
return;
}
const ctx = InMemoryAccountContext.New(this.state.privateKey);
localStorage.setItem("MPK", this.state.privateKey.hex);
setSignInState("local");
this.props.emit({
type: "SignInEvent",
ctx: ctx,
this.setState({
step: "enter local pin",
});
};
@ -140,13 +146,13 @@ export class SignIn extends Component<Props, State> {
this.setState({
privateKey: PrivateKey.Generate(),
privateKeyError: "",
state: "newAccount",
step: "newAccount",
});
};
cancelNew = () => {
this.setState({
state: "enterPrivateKey",
step: "enterPrivateKey",
privateKey: undefined,
});
};
@ -170,7 +176,7 @@ export class SignIn extends Component<Props, State> {
};
render() {
if (this.state.state == "newAccount") {
if (this.state.step == "newAccount") {
return (
<div class={this.styles.container}>
<div class={this.styles.form}>
@ -181,7 +187,6 @@ export class SignIn extends Component<Props, State> {
<p class={this.styles.hint}>
Please back up your <strong>Private Key</strong>
</p>
<div class={this.styles.block}></div>
<button
onClick={this.cancelNew}
class={this.styles.cancelButton}
@ -197,49 +202,257 @@ export class SignIn extends Component<Props, State> {
</div>
</div>
);
}
return (
<div class={this.styles.container}>
<div class={this.styles.form}>
<img class={this.styles.logo} src="logo.webp" alt="Logo" />
<h1 class={this.styles.title}>Blowater</h1>
<p class={this.styles.subTitle}>A delightful Nostr client that focuses on DM</p>
<input
onInput={(e) => this.inputPrivateKey(e.currentTarget.value)}
placeholder="Input your private key here"
type="password"
class={this.styles.input}
autofocus
/>
<p class={this.styles.hint}>
<span class={this.styles.isError(this.state.privateKeyError)}>
Private Key has to be <strong>64</strong> letters hex-decimal or{" "}
<strong>63</strong> letters nsec string.
</span>{" "}
Don't have an account yet?{" "}
<button onClick={this.newAccount} class={this.styles.newButton}>create one!</button>
</p>
<div class={this.styles.block}></div>
} else if (this.state.step == "enter local pin") {
return (
<div class={this.styles.container}>
<div class="block text-white">
Please enter a pin that is used to encrypt your private key on-device
</div>
<input ref={this.localPinInput} type="password"></input>
<button
onClick={this.signInWithPrivateKey}
class={this.styles.signInButton}
class="text-white border mt-1 px-2 hover:bg-zinc-200"
onClick={() => {
const input = this.localPinInput.current;
if (input) {
const pin = input.value;
this.setState({
localPin: pin,
step: "confirm local pin",
});
}
}}
>
Sign In
confirm
</button>
<button
onClick={async () => await this.signInWithExtension()}
class={this.styles.signInButton}
>
Sign in with Nostr Extension
</button>
<p class={this.styles.hint}>
<span class={this.styles.isError(this.state.nip07Error)}>
{this.state.nip07Error}
</span>
</p>
</div>
);
} else if (this.state.step == "confirm local pin") {
const input = this.localPinInput.current;
if (input) {
input.value = "";
}
return (
<div class={this.styles.container}>
<div class="block text-white">Please enter the pin you just typed</div>
<input ref={this.localPinInput} type="password"></input>
<button
class="text-white border mt-1 px-2 hover:bg-zinc-200"
onClick={() => {
const input = this.localPinInput.current;
if (input) {
const pin = input.value;
console.log(this.state.localPin, pin, this.state.localPin == pin);
if (this.state.localPin == pin) {
const ctx = InMemoryAccountContext.New(this.state.privateKey);
LocalPrivateKeyController.setKey(pin, this.state.privateKey);
setSignInState("local");
this.props.emit({
type: "SignInEvent",
ctx: ctx,
});
} else {
}
}
}}
>
confirm
</button>
</div>
);
} else {
return (
<div class={this.styles.container}>
<div class={this.styles.form}>
<img class={this.styles.logo} src="logo.webp" alt="Logo" />
<h1 class={this.styles.title}>Blowater</h1>
<p class={this.styles.subTitle}>A delightful Nostr client that focuses on DM</p>
<input
onInput={(e) => this.inputPrivateKey(e.currentTarget.value)}
placeholder="Input your private key here"
type="password"
class={this.styles.input}
autofocus
/>
<p class={this.styles.hint}>
<span class={this.styles.isError(this.state.privateKeyError)}>
Private Key has to be <strong>64</strong> letters hex-decimal or{" "}
<strong>63</strong> letters nsec string.
</span>{" "}
Don't have an account yet?{" "}
<button onClick={this.newAccount} class={this.styles.newButton}>
create one!
</button>
</p>
<div class={"flex-1"}></div>
<button
onClick={this.signInWithPrivateKey}
class={this.styles.signInButton}
>
Sign In
</button>
<button
onClick={async () => await this.signInWithExtension()}
class={this.styles.signInButton}
>
Sign in with Nostr Extension
</button>
<p class={this.styles.hint}>
<span class={this.styles.isError(this.state.nip07Error)}>
{this.state.nip07Error}
</span>
</p>
</div>
</div>
);
}
}
}
export const forgot_pin = Symbol("forgot_pin");
class AskForLocalPin extends Component<{
resolve: (pin: string | typeof forgot_pin) => void;
err: Error | undefined;
}, {}> {
input = createRef<HTMLInputElement>();
render() {
return (
<div
class={`h-screen w-screen bg-[${PrimaryBackgroundColor}] ` +
`flex flex-col items-center justify-center p-4 overflow-y-auto`}
>
<div class="block text-white">Please enter your local pin</div>
<input ref={this.input} type="password"></input>
<button
class="text-white border mt-1 px-2 hover:bg-zinc-200"
onClick={() => {
const input = this.input.current;
if (input) {
this.props.resolve(input.value);
}
}}
>
confirm
</button>
<button
class="text-white border mt-1 px-2 hover:bg-zinc-200"
onClick={() => {
const input = this.input.current;
if (input) {
this.props.resolve(forgot_pin);
}
}}
>
Forgot the pin
</button>
{this.props.err ? <div class="block text-white">{this.props.err.message}</div> : undefined}
</div>
);
}
}
export async function getPinFromUser(err: Error | undefined) {
return new Promise<string | typeof forgot_pin>((resolve) => {
console.log(err);
render(<AskForLocalPin resolve={resolve} err={err}></AskForLocalPin>, document.body);
});
}
export class LocalPrivateKeyController {
static cleanOldVersionDate() {
localStorage.removeItem("MPK");
}
static async setKey(pin: string, pri: PrivateKey) {
// hash the pin
const encoder = new TextEncoder();
const data = encoder.encode(pin);
const hash = await crypto.subtle.digest("SHA-256", data);
// encrypt the private key
const key = await crypto.subtle.importKey(
"raw",
hash,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"],
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv },
key,
new TextEncoder().encode(pri.hex),
);
// store the key
localStorage.setItem(
`private key`,
JSON.stringify({
encrypted: toBase64(new Uint8Array(encrypted)),
iv: toBase64(new Uint8Array(iv)),
}),
);
}
static async getKey(pin: string): Promise<PrivateKey | Error | undefined> {
// Retrieve the encrypted data from localStorage
const stored = localStorage.getItem(`private key`);
if (!stored) return undefined;
const { encrypted, iv } = JSON.parse(stored);
const encryptedData = decodeBase64(encrypted);
const ivData = decodeBase64(iv);
// Hash the pin
const encoder = new TextEncoder();
const data = encoder.encode(pin);
const hash = await crypto.subtle.digest("SHA-256", data);
// Decrypt the private key
const key = await crypto.subtle.importKey(
"raw",
hash,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"],
);
try {
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: ivData },
key,
encryptedData,
);
const private_hex = new TextDecoder().decode(decrypted);
return PrivateKey.FromHex(private_hex);
} catch (e) {
return new Error("wrong pin");
}
}
}
LocalPrivateKeyController.cleanOldVersionDate();
function toBase64(uInt8Array: Uint8Array) {
let strChunks = new Array(uInt8Array.length);
let i = 0;
for (let byte of uInt8Array) {
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
i++;
}
return btoa(strChunks.join(""));
}
function decodeBase64(base64String: string) {
const binaryString = atob(base64String);
const length = binaryString.length;
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}

View File

@ -49,7 +49,7 @@ build: fmt
cp -rv app/UI/assets/ build-pwa/
deno bundle app/UI/_main.tsx build-pwa/main.mjs
test-ui:
test-ui: fmt
deno bundle --config=./deno.json app/UI/$(page).test.tsx build-pwa/main.mjs
file_server -p $(port) build-pwa