Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NIP06 support #425

Merged
merged 5 commits into from
Mar 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Snort supports the following NIP's:
- [ ] NIP-03: OpenTimestamps Attestations for Events
- [x] NIP-04: Encrypted Direct Message
- [x] NIP-05: Mapping Nostr keys to DNS-based internet identifiers
- [ ] NIP-06: Basic key derivation from mnemonic seed phrase
- [x] NIP-06: Basic key derivation from mnemonic seed phrase
- [x] NIP-07: `window.nostr` capability for web browsers
- [x] NIP-08: Handling Mentions
- [x] NIP-09: Event Deletion
Expand Down
2 changes: 2 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"@noble/secp256k1": "^1.7.0",
"@protobufjs/base64": "^1.1.2",
"@reduxjs/toolkit": "^1.9.1",
"@scure/bip32": "^1.1.5",
"@scure/bip39": "^1.1.1",
"@snort/nostr": "^1.0.0",
"@szhsin/react-menu": "^3.3.1",
"base32-decode": "^1.0.0",
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/Const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,23 @@ export const ZapperSpam = [
"e1ff3bfdd4e40315959b08b4fcc8245eaa514637e1d4ec2ae166b743341be1af", // benthecarman
];

/**
* NIP06-defined derivation path for private keys
*/
export const DerivationPath = "m/44'/1237'/0'/0/0";

/**
* Regex to match email address
*/
export const EmailRegex =
// eslint-disable-next-line no-useless-escape
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

/**
* Regex to match a mnemonic seed
*/
export const MnemonicRegex = /^([^\s]+\s){11}[^\s]+$/;

/**
* Extract file extensions regex
*/
Expand Down
22 changes: 13 additions & 9 deletions packages/app/src/Pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { useIntl, FormattedMessage } from "react-intl";

import { RootState } from "State/Store";
import { setPrivateKey, setPublicKey, setRelays, setGeneratedPrivateKey } from "State/Login";
import { DefaultRelays, EmailRegex } from "Const";
import { bech32ToHex, unwrap } from "Util";
import { DefaultRelays, EmailRegex, MnemonicRegex } from "Const";
import { bech32ToHex, generateBip39Entropy, entropyToDerivedKey, unwrap } from "Util";
import { HexKey } from "@snort/nostr";
import ZapButton from "Element/ZapButton";
// import useImgProxy from "Feed/ImgProxy";
Expand Down Expand Up @@ -97,12 +97,14 @@ export default function LoginPage() {
} else if (key.match(EmailRegex)) {
const hexKey = await getNip05PubKey(key);
dispatch(setPublicKey(hexKey));
} else if (key.match(MnemonicRegex)) {
const ent = generateBip39Entropy(key);
const keyHex = entropyToDerivedKey(ent);
dispatch(setPrivateKey(keyHex));
} else if (secp.utils.isValidPrivateKey(key)) {
dispatch(setPrivateKey(key));
} else {
if (secp.utils.isValidPrivateKey(key)) {
dispatch(setPrivateKey(key));
} else {
throw new Error("INVALID PRIVATE KEY");
}
throw new Error("INVALID PRIVATE KEY");
}
} catch (e) {
setError(`Failed to load NIP-05 pub key (${e})`);
Expand All @@ -111,8 +113,10 @@ export default function LoginPage() {
}

async function makeRandomKey() {
const newKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey());
dispatch(setGeneratedPrivateKey(newKey));
const ent = generateBip39Entropy();
const entHex = secp.utils.bytesToHex(ent);
const newKeyHex = entropyToDerivedKey(ent);
dispatch(setGeneratedPrivateKey({ key: newKeyHex, entropy: entHex }));
navigate("/new");
}

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/Pages/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,5 @@ export default defineMessages({
},
Bookmarks: { defaultMessage: "Bookmarks" },
BookmarksCount: { defaultMessage: "{n} Bookmarks" },
KeyPlaceholder: { defaultMessage: "nsec, npub, nip-05, hex" },
KeyPlaceholder: { defaultMessage: "nsec, npub, nip-05, hex, mnemonic" },
});
10 changes: 9 additions & 1 deletion packages/app/src/Pages/new/DiscoverFollows.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import { useIntl, FormattedMessage } from "react-intl";
import { useDispatch } from "react-redux";
import { useNavigate, Link } from "react-router-dom";
import { RecommendedFollows } from "Const";
import Logo from "Element/Logo";
import FollowListBase from "Element/FollowListBase";
import { useMemo } from "react";
import { clearEntropy } from "State/Login";

import messages from "./messages";

export default function DiscoverFollows() {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const navigate = useNavigate();
const sortedReccomends = useMemo(() => {
return RecommendedFollows.sort(() => (Math.random() >= 0.5 ? -1 : 1));
}, []);

async function clearEntropyAndGo() {
dispatch(clearEntropy());
navigate("/");
}

return (
<div className="main-content new-user" dir="auto">
<Logo />
Expand All @@ -27,7 +35,7 @@ export default function DiscoverFollows() {
<FormattedMessage {...messages.Share} values={{ link: <Link to="/">{formatMessage(messages.World)}</Link> }} />
</p>
<div className="next-actions continue-actions">
<button type="button" onClick={() => navigate("/")}>
<button type="button" onClick={() => clearEntropyAndGo()}>
<FormattedMessage {...messages.Done} />{" "}
</button>
</div>
Expand Down
8 changes: 6 additions & 2 deletions packages/app/src/Pages/new/NewUserFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Logo from "Element/Logo";
import { CollapsedSection } from "Element/Collapsed";
import Copy from "Element/Copy";
import { RootState } from "State/Store";
import { hexToBech32 } from "Util";
import { hexToBech32, hexToMnemonic } from "Util";

import messages from "./messages";

Expand Down Expand Up @@ -68,7 +68,7 @@ const Extensions = () => {
};

export default function NewUserFlow() {
const { publicKey, privateKey } = useSelector((s: RootState) => s.login);
const { publicKey, privateKey, generatedEntropy } = useSelector((s: RootState) => s.login);
const navigate = useNavigate();

return (
Expand All @@ -91,6 +91,10 @@ export default function NewUserFlow() {
<FormattedMessage {...messages.YourPrivkey} />
</h2>
<Copy text={hexToBech32("nsec", privateKey ?? "")} />
<h2>
<FormattedMessage {...messages.YourMnemonic} />
</h2>
<Copy text={hexToMnemonic(generatedEntropy ?? "")} />
<div className="next-actions">
<button type="button" onClick={() => navigate("/new/username")}>
<FormattedMessage {...messages.KeysSaved} />{" "}
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/Pages/new/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineMessages({
},
YourPubkey: { defaultMessage: "Your public key" },
YourPrivkey: { defaultMessage: "Your private key" },
YourMnemonic: { defaultMessage: "Your mnemonic phrase" },
KeysSaved: { defaultMessage: "I have saved my keys, continue" },
WhatIsSnort: { defaultMessage: "What is Snort and how does it work?" },
WhatIsSnortIntro: {
Expand Down
23 changes: 19 additions & 4 deletions packages/app/src/State/Login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export interface LoginStore {
*/
privateKey?: HexKey;

/**
* BIP39-generated, hex-encoded entropy
*/
generatedEntropy?: string;

/**
* Current users public key
*/
Expand Down Expand Up @@ -253,6 +258,11 @@ export interface SetFollowsPayload {
createdAt: number;
}

export interface SetGeneratedKeyPayload {
key: HexKey;
entropy: HexKey;
w3irdrobot marked this conversation as resolved.
Show resolved Hide resolved
}

export const ReadPreferences = () => {
const pref = window.localStorage.getItem(UserPreferencesKey);
if (pref) {
Expand Down Expand Up @@ -315,12 +325,16 @@ const LoginSlice = createSlice({
window.localStorage.setItem(PrivateKeyItem, action.payload);
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload));
},
setGeneratedPrivateKey: (state, action: PayloadAction<HexKey>) => {
setGeneratedPrivateKey: (state, action: PayloadAction<SetGeneratedKeyPayload>) => {
state.loggedOut = false;
state.newUserKey = true;
state.privateKey = action.payload;
window.localStorage.setItem(PrivateKeyItem, action.payload);
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload));
state.privateKey = action.payload.key;
state.generatedEntropy = action.payload.entropy;
window.localStorage.setItem(PrivateKeyItem, action.payload.key);
v0l marked this conversation as resolved.
Show resolved Hide resolved
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload.key));
},
clearEntropy: state => {
state.generatedEntropy = undefined;
},
setPublicKey: (state, action: PayloadAction<HexKey>) => {
window.localStorage.setItem(PublicKeyItem, action.payload);
Expand Down Expand Up @@ -468,6 +482,7 @@ export const {
init,
setPrivateKey,
setGeneratedPrivateKey,
clearEntropy,
setPublicKey,
setRelays,
removeRelay,
Expand Down
37 changes: 37 additions & 0 deletions packages/app/src/Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { decode as invoiceDecode } from "light-bolt11-decoder";
import { bech32 } from "bech32";
import base32Decode from "base32-decode";
import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr";
import * as bip39 from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";
import { HDKey } from "@scure/bip32";

import { DerivationPath } from "Const";
import { MetadataCache } from "State/Users";

export const sha256 = (str: string) => {
Expand Down Expand Up @@ -100,6 +104,39 @@ export function hexToBech32(hrp: string, hex?: string) {
}
}

export function generateBip39Entropy(mnemonic?: string): Uint8Array {
try {
const mn = mnemonic ?? bip39.generateMnemonic(wordlist);
return bip39.mnemonicToEntropy(mn, wordlist);
} catch (e) {
throw new Error("INVALID MNEMONIC PHRASE");
}
}

/**
* Convert hex-encoded entropy into mnemonic phrase
*/
export function hexToMnemonic(hex: string): string {
const bytes = secp.utils.hexToBytes(hex);
return bip39.entropyToMnemonic(bytes, wordlist);
}

/**
* Convert mnemonic phrase into hex-encoded private key
* using the derivation path specified in NIP06
* @param mnemonic the mnemonic-encoded entropy
*/
export function entropyToDerivedKey(entropy: Uint8Array): string {
const masterKey = HDKey.fromMasterSeed(entropy);
const newKey = masterKey.derive(DerivationPath);

if (!newKey.privateKey) {
throw new Error("INVALID KEY DERIVATION");
}

return secp.utils.bytesToHex(newKey.privateKey);
}

/**
* Convert hex pubkey to bech32 link url
*/
Expand Down
27 changes: 24 additions & 3 deletions packages/app/src/lang.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,6 @@
"B6+XJy": {
"defaultMessage": "zapped"
},
"B6H7eJ": {
"defaultMessage": "nsec, npub, nip-05, hex"
},
"BOUMjw": {
"defaultMessage": "No nostr users found for {twitterUsername}"
},
Expand Down Expand Up @@ -312,6 +309,9 @@
"IEwZvs": {
"defaultMessage": "Are you sure you want to unpin this note?"
},
"IKOPx/": {
"defaultMessage": "Donate Page"
},
"INSqIz": {
"defaultMessage": "Twitter username..."
},
Expand All @@ -327,6 +327,9 @@
"JHEHCk": {
"defaultMessage": "Zaps ({n})"
},
"JXtsQW": {
"defaultMessage": "Fast Zap Donation"
},
"JkLHGw": {
"defaultMessage": "Website"
},
Expand All @@ -348,6 +351,9 @@
"KahimY": {
"defaultMessage": "Unknown event kind: {kind}"
},
"L7SZPr": {
"defaultMessage": "For more information about donations see {link}."
},
"LXxsbk": {
"defaultMessage": "Anonymous"
},
Expand Down Expand Up @@ -410,6 +416,9 @@
"PCSt5T": {
"defaultMessage": "Preferences"
},
"PLSbmL": {
"defaultMessage": "Your mnemonic phrase"
},
"Pe0ogR": {
"defaultMessage": "Theme"
},
Expand Down Expand Up @@ -439,6 +448,9 @@
"RahCRH": {
"defaultMessage": "Expired"
},
"RfhLwC": {
"defaultMessage": "By: {author}"
},
"RhDAoS": {
"defaultMessage": "Are you sure you want to delete {id}"
},
Expand Down Expand Up @@ -492,6 +504,9 @@
"WxthCV": {
"defaultMessage": "e.g. Jack"
},
"X7xU8J": {
"defaultMessage": "nsec, npub, nip-05, hex, mnemonic"
},
"XgWvGA": {
"defaultMessage": "Reactions"
},
Expand Down Expand Up @@ -599,6 +614,9 @@
"gjBiyj": {
"defaultMessage": "Loading..."
},
"h8XMJL": {
"defaultMessage": "Badges"
},
"hCUivF": {
"defaultMessage": "Notes will stream in real time into global and posts tab"
},
Expand Down Expand Up @@ -782,6 +800,9 @@
"rudscU": {
"defaultMessage": "Failed to load follows, please try again later"
},
"sBz4+I": {
"defaultMessage": "For each Fast Zap an additional {percentage}% ({amount} sats) of the zap amount will be sent to the Snort developers as a donation."
},
"sWnYKw": {
"defaultMessage": "Snort is designed to have a similar experience to Twitter."
},
Expand Down