├── .eslintrc.json
├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── app
├── [compositeKey]
│ └── page.tsx
├── components
│ ├── analytics.tsx
│ ├── error.tsx
│ ├── stats.tsx
│ ├── testimony.tsx
│ └── title.tsx
├── deploy
│ └── page.tsx
├── globals.css
├── head.tsx
├── header.tsx
├── layout.tsx
├── page.tsx
├── share
│ └── page.tsx
└── unseal
│ └── page.tsx
├── img
└── envshare.png
├── jest.config.js
├── next.config.js
├── package.json
├── pages
└── api
│ └── v1
│ ├── load.ts
│ ├── og.tsx
│ ├── secret
│ ├── [id].ts
│ └── index.ts
│ └── store.ts
├── pkg
├── constants.ts
├── encoding.test.ts
├── encoding.ts
├── encryption.test.ts
├── encryption.ts
└── id.ts
├── pnpm-lock.yaml
├── postcss.config.js
├── public
└── fonts
│ └── Inter-SemiBold.ttf
├── rome.json
├── tailwind.config.js
├── tsconfig.json
└── util
└── base58.ts
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true
4 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Andreas Thomas
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
EnvShare
3 | Share Environment Variables Securely
4 |
5 |
6 |
9 |
10 |
11 | EnvShare is a simple tool to share environment variables securely. It uses
12 | **AES-GCM** to encrypt your data before sending it to the server. The encryption
13 | key never leaves your browser.
14 |
15 | ## Features
16 |
17 | - **Shareable Links:** Share your environment variables securely by sending a
18 | link
19 | - **End-to-End Encryption:** AES-GCM encryption is used to encrypt your data
20 | before sending it to the server
21 | - **Limit number of reads:** Limit the number of times a link can be read
22 | - **Auto Expire:** Automatically expire links and delete data after a certain
23 | time
24 |
25 |
26 |
27 | 
28 |
29 | ## Built with
30 |
31 | - [Next.js](https://nextjs.org)
32 | - [tailwindcss](https://tailwindcss.com)
33 | - Deployed on [Vercel](https://vercel.com?utm_source=envshare)
34 | - Data stored on [Upstash](https://upstash.com?utm_source=envshare)
35 |
36 | ## Deploy your own
37 |
38 | Detailed instructions can be found [here](https://envshare.dev/deploy)
39 |
40 | All you need is a Redis database on Upstash and a Vercel account. Click the
41 | button below to clone and deploy:
42 |
43 | [](https://vercel.com/new/clone?demo-title=EnvShare&demo-description=Simple%20Next.js%20%2B%20Upstash%20app%20to%20share%20environment%20variables%20securely%20using%20AES-GCM%20encryption.&demo-url=https%3A%2F%2Fenvshare.dev%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F5SaFBHXp5FBFJbsTzVqIJ3%2Ff0f8382369b7642fd8103debb9025c11%2Fenvshare.png&project-name=EnvShare&repository-name=envshare&repository-url=https%3A%2F%2Fgithub.com%2Fchronark%2Fenvshare&from=templates&integration-ids=oac_V3R1GIpkoJorr6fqyiwdhl17)
44 |
45 |
46 | ## Sponsors
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | Upstash: Serverless Database for Redis
56 |
57 |
58 | Serverless Redis with global replication and durable storage
59 | Price scales to zero with per request pricing
60 | Built-in REST API designed for serverless and edge functions
61 |
62 |
63 | [Start for free in 30 seconds!](https://upstash.com/?utm_source=envshare)
64 |
65 |
66 |
67 |
68 |
69 | ## Configuration
70 |
71 | ### Environment Variables
72 |
73 | `ENABLE_VERCEL_ANALYTICS` Any truthy value will enable Vercel Analytics. This is turned off by default
74 |
75 | ## Contributing
76 |
77 | This repository uses `pnpm` to manage dependencies. Install it using
78 | `npm install -g pnpm`
79 |
80 | Please run `pnpm fmt` before committing to format the code.
81 |
82 | ## Docs
83 |
84 | Docs in the README are temporary and will be moved to the website soon.
85 |
86 | ### API
87 |
88 | #### Store a secret
89 |
90 | **PLEASE NEVER EVER UPLOAD UNENCRYPTED SECRETS.**
91 |
92 | This endpoint is only meant to store **already encrypted** secrets. The
93 | encrypted secrets are stored in plain text.
94 |
95 | ```sh-session
96 | $ curl -XPOST -s https://envshare.dev/api/v1/secret -d "already-encrypted-secret"
97 | ```
98 |
99 | You can add optional headers to configure the ttl and number of reads.
100 |
101 | ```sh-session
102 | $ curl -XPOST -s https://envshare.dev/api/v1/secret -d "already-encrypted-secret" -H "envshare-ttl: 3600" -H "envshare-reads: 10"
103 | ```
104 |
105 | - Omitting the `envshare-ttl` header will set a default of 30 days. Disable the
106 | ttl by setting it to 0. (`envshare-ttl: 0`)
107 | - Omitting the `envshare-reads` header will simply disable it and allow reading
108 | for an unlimited number of times.
109 |
110 | This endpoint returns a JSON response with the secret id:
111 |
112 | ```json
113 | {
114 | "data": {
115 | "id": "HdPbXgpvUvNk43oxSdK97u",
116 | "ttl": 86400,
117 | "reads": 2,
118 | "expiresAt": "2023-01-19T20:47:28.383Z",
119 | "url": "http://envshare.dev/api/v1/secret/HdPbXgpvUvNk43oxSdK97u"
120 | }
121 | }
122 | ```
123 |
124 | #### Retrieve a secret
125 |
126 | You need an id to retrieve a secret. The id is returned when you store a secret.
127 |
128 | ```sh-session
129 | $ curl -s https://envshare.dev/api/v1/secret/HdPbXgpvUvNk43oxSdK97u
130 | ```
131 |
132 | ```json
133 | {
134 | "data": {
135 | "secret": "Hello",
136 | "remainingReads": 1
137 | }
138 | }
139 | ```
140 |
--------------------------------------------------------------------------------
/app/[compositeKey]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | // This page is here for backwards compatibility with old links.
4 | // Old links were of the form /{compositeKey} and now they are of the form /unseal#{compositeKey}
5 | export default function Page(props: { params: { compositeKey: string } }) {
6 | return redirect(`/unseal#${props.params.compositeKey}`);
7 | }
8 |
--------------------------------------------------------------------------------
/app/components/analytics.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Analytics as VercelAnalytics } from "@vercel/analytics/react";
3 |
4 | const track = ["/", "/share", "/deploy", "/unseal"];
5 |
6 | export function Analytics() {
7 | return (
8 | {
10 | const url = new URL(event.url);
11 | if (!track.includes(url.pathname)) {
12 | url.pathname = "/__redacted";
13 | return {
14 | ...event,
15 | url: url.href,
16 | };
17 | }
18 | return event;
19 | }}
20 | />
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/app/components/error.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | message: string;
3 | };
4 |
5 | export const ErrorMessage: React.FC = ({ message }) => {
6 | return (
7 |
8 | {message}
9 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/app/components/stats.tsx:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 |
3 | const redis = Redis.fromEnv();
4 | export const revalidate = 60;
5 |
6 | export const Stats = asyncComponent(async () => {
7 | const [reads, writes] = await redis
8 | .pipeline()
9 | .get("envshare:metrics:reads")
10 | .get("envshare:metrics:writes")
11 | .exec<[number, number]>();
12 | const stars = await fetch("https://api.github.com/repos/chronark/envshare")
13 | .then((res) => res.json())
14 | .then((json) => json.stargazers_count as number);
15 |
16 | const stats = [
17 | {
18 | label: "Documents Encrypted",
19 | value: writes,
20 | },
21 | {
22 | label: "Documents Decrypted",
23 | value: reads,
24 | },
25 | ] satisfies { label: string; value: number }[];
26 |
27 | if (stars) {
28 | stats.push({
29 | label: "GitHub Stars",
30 | value: stars,
31 | });
32 | }
33 |
34 | return (
35 |
36 |
37 | {stats.map(({ label, value }) => (
38 |
42 |
43 | {Intl.NumberFormat("en-US", { notation: "compact" }).format(value)}
44 |
45 | {label}
46 |
47 | ))}
48 |
49 |
50 | );
51 | });
52 |
53 | // stupid hack to make "server components" actually work with components
54 | // https://www.youtube.com/watch?v=h_9Vx6kio2s
55 | function asyncComponent(fn: (arg: T) => Promise): (arg: T) => R {
56 | return fn as (arg: T) => R;
57 | }
58 |
--------------------------------------------------------------------------------
/app/components/testimony.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Image from "next/image";
3 | import Link from "next/link";
4 | import { Props } from "next/script";
5 | import React, { PropsWithChildren } from "react";
6 |
7 | const TwitterHandle: React.FC = ({ children }) => {
8 | return {children} ;
9 | };
10 |
11 | const Author: React.FC> = ({ children, href }) => (
12 |
13 | {children}
14 |
15 | );
16 |
17 | const Title: React.FC> = ({ children, href }) => (
18 |
24 | {children}
25 |
26 | );
27 |
28 | export const Testimonials = () => {
29 | const posts: {
30 | content: React.ReactNode;
31 | link: string;
32 | author: {
33 | name: React.ReactNode;
34 | title?: React.ReactNode;
35 | image: string;
36 | };
37 | }[] = [
38 | {
39 | content: (
40 |
41 |
42 | My cursory audit of @chronark_ 's envshare:
43 |
44 |
45 | It is light, extremely functional, and does its symmetric block cipher correctly, unique initialization
46 | vectors, decryption keys derived securely.
47 |
48 |
49 |
Easily modified to remove minimal analytics. Superior to Privnote.
50 |
51 |
Self-hosting is easy. 👏
52 |
53 | ),
54 | link: "https://twitter.com/FrederikMarkor/status/1615299856205250560",
55 | author: {
56 | name: Frederik Markor ,
57 | title: CEO @discreet ,
58 | image: "https://pbs.twimg.com/profile_images/1438061314010664962/NecuMIGR_400x400.jpg",
59 | },
60 | },
61 | {
62 | content: (
63 |
64 |
I'm particularly chuffed about this launch, for a couple of reasons:
65 |
66 |
67 | ◆ Built on @nextjs + @upstash , hosted on{" "}
68 | @vercel
69 |
70 | ◆ 100% free to use & open source
71 | ◆ One-click deploy via Vercel + Upstash integration
72 |
73 |
Deploy your own → http://vercel.fyi/envshare
74 |
75 | ),
76 | link: "https://twitter.com/steventey/status/1615035241772482567?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1615035241772482567%7Ctwgr%5E1db44bb10c690189e24c980fcd787299961c34c6%7Ctwcon%5Es1_&ref_url=https%3A%2F%2Fpublish.twitter.com%2F%3Fquery%3Dhttps3A2F2Ftwitter.com2Fsteventey2Fstatus2F1615035241772482567widget%3DTweet",
77 | author: {
78 | name: Steven Tey ,
79 | title: Senior Developer Advocate at Vercel ,
80 | image: "https://pbs.twimg.com/profile_images/1506792347840888834/dS-r50Je_400x400.jpg",
81 | },
82 | },
83 | {
84 | content: (
85 |
86 |
87 | Congratulations on the launch @chronark_ 👏! This is such a valuable product
88 | for developers. Icing on the cake is that it's open source! ✨
89 |
90 |
91 | ),
92 | link: "https://twitter.com/DesignSiddharth/status/1615293209164546048",
93 | author: {
94 | name: @DesignSiddharth ,
95 | image: "https://pbs.twimg.com/profile_images/1613772710009765888/MbSblJYf_400x400.jpg",
96 | },
97 | },
98 | ];
99 |
100 | return (
101 |
124 | );
125 | };
126 |
--------------------------------------------------------------------------------
/app/components/title.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from "react";
2 |
3 | export const Title: React.FC = ({ children }): JSX.Element => {
4 | return (
5 |
6 | {children}
7 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/app/deploy/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
3 | import Link from "next/link";
4 | import { Title } from "@components/title";
5 | import React from "react";
6 | const steps: {
7 | name: string;
8 | description: string | React.ReactNode;
9 | cta?: React.ReactNode;
10 | }[] = [
11 | {
12 | name: "Create a new Redis database on Upstash",
13 | description: (
14 | <>
15 | Upstash offers a serverless Redis database with a generous free tier of up to 10,000 requests per day. That's
16 | more than enough.
17 |
18 | Click the button below to sign up and create a new Redis database on Upstash.
19 | >
20 | ),
21 | cta: (
22 |
26 | Create Database
27 |
28 |
29 | ),
30 | },
31 | {
32 | name: "Copy the REST connection credentials",
33 | description: (
34 |
35 | After creating the database, scroll to the bottom and make a note of UPSTASH_REDIS_REST_URL
and{" "}
36 | UPSTASH_REDIS_REST_TOKEN
, you need them in the next step
37 |
38 | ),
39 | },
40 | {
41 | name: "Deploy to Vercel",
42 | description: "Deploy the app to Vercel and paste the connection credentials into the environment variables.",
43 | cta: (
44 |
48 | Deploy
49 |
50 |
51 | ),
52 | },
53 | ];
54 |
55 | export default function Deploy() {
56 | return (
57 |
58 |
Deploy EnvShare for Free
59 |
60 | You can deploy your own hosted version of EnvShare, you just need an Upstash and Vercel account.
61 |
62 |
63 | {steps.map((step, stepIdx) => (
64 |
65 |
69 |
70 |
71 | {stepIdx + 1}
72 |
73 |
74 |
75 |
76 | {step.name}
77 |
78 |
79 |
80 | {step.description}
81 |
82 |
{step.cta}
83 |
84 |
85 | ))}
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer utilities {
6 | input[type="number"]::-webkit-inner-spin-button,
7 | input[type="number"]::-webkit-outer-spin-button {
8 | @apply appearance-none;
9 | }
10 |
11 |
12 | input[type="file"] {
13 | @apply appearance-none;
14 | }
15 | }
--------------------------------------------------------------------------------
/app/head.tsx:
--------------------------------------------------------------------------------
1 | export default function Head({ title, subtitle }: { title: string; subtitle: string }) {
2 | // Fallback tagline
3 | title ??= "Share Environment Variables Securely";
4 | subtitle ??= "EnvShare";
5 |
6 | const baseUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000";
7 |
8 | const url = new URL("/api/v1/og", baseUrl);
9 | url.searchParams.set("title", title);
10 | url.searchParams.set("subtitle", subtitle);
11 |
12 | return (
13 | <>
14 | EnvShare
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {/* Open Graph / Facebook */}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | {/* Twitter */}
35 |
36 |
37 |
38 |
39 |
40 | >
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/app/header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from "react";
3 | import Link from "next/link";
4 | import { usePathname } from "next/navigation";
5 |
6 | const navigation = [
7 | {
8 | name: "Share",
9 | href: "/share",
10 | },
11 | {
12 | name: "Unseal",
13 | href: "/unseal",
14 | },
15 |
16 | {
17 | name: "Deploy",
18 | href: "/deploy",
19 | },
20 | {
21 | name: "GitHub",
22 | href: "https://github.com/chronark/envshare",
23 | external: true,
24 | },
25 | ] satisfies { name: string; href: string; external?: boolean }[];
26 |
27 | export const Header: React.FC = () => {
28 | const pathname = usePathname();
29 | return (
30 |
31 |
32 |
33 |
34 | EnvShare
35 |
36 | {/* Desktop navigation */}
37 |
38 |
39 | {navigation.map((item) => (
40 |
41 |
48 | {item.name}
49 |
50 |
51 | ))}
52 |
53 |
54 |
55 |
56 |
57 | {/* Fancy fading bottom border */}
58 |
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import { Inter } from "@next/font/google";
3 | import Link from "next/link";
4 | import { Header } from "./header";
5 |
6 | import { Analytics } from "@components/analytics";
7 | const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
8 |
9 | export default function RootLayout({
10 | children,
11 | }: {
12 | children: React.ReactNode;
13 | }) {
14 | return (
15 |
16 |
17 |
18 | {
19 | // Not everyone will want to host envshare on Vercel, so it makes sense to make this opt-in.
20 | process.env.ENABLE_VERCEL_ANALYTICS ? : null
21 | }
22 |
23 |
24 |
25 | {children}
26 |
27 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Stats } from "./components/stats";
3 | import { Testimonials } from "./components/testimony";
4 |
5 | export default function Home() {
6 | return (
7 |
8 |
9 |
10 |
14 | EnvShare is Open Source on{" "}
15 |
16 | GitHub →
17 |
18 |
19 |
20 |
21 |
22 | Share Environment Variables Securely
23 |
24 |
25 | Your document is encrypted in your browser before being stored for a limited period of time and read
26 | operations. Unencrypted data never leaves your browser.
27 |
28 |
29 |
33 | Deploy
34 |
35 |
39 | Share
40 | →
41 |
42 |
43 |
44 |
45 |
Used and trusted by a growing community
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/app/share/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { toBase58 } from "util/base58";
3 | import { useState, Fragment } from "react";
4 | import { Cog6ToothIcon, ClipboardDocumentIcon, ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";
5 | import { Title } from "@components/title";
6 | import { encrypt } from "pkg/encryption";
7 | import { ErrorMessage } from "@components/error";
8 | import { encodeCompositeKey } from "pkg/encoding";
9 | import { LATEST_KEY_VERSION } from "pkg/constants";
10 |
11 | export default function Home() {
12 | const [text, setText] = useState("");
13 | const [reads, setReads] = useState(999);
14 |
15 | const [ttl, setTtl] = useState(7);
16 | const [ttlMultiplier, setTtlMultiplier] = useState(60 * 60 * 24);
17 | const [loading, setLoading] = useState(false);
18 | const [error, setError] = useState("");
19 | const [copied, setCopied] = useState(false);
20 |
21 | const [link, setLink] = useState("");
22 |
23 | const onSubmit = async () => {
24 | try {
25 | setError("");
26 | setLink("");
27 | setLoading(true);
28 |
29 | const { encrypted, iv, key } = await encrypt(text);
30 |
31 | const { id } = (await fetch("/api/v1/store", {
32 | method: "POST",
33 | body: JSON.stringify({
34 | ttl: ttl * ttlMultiplier,
35 | reads,
36 | encrypted: toBase58(encrypted),
37 | iv: toBase58(iv),
38 | }),
39 | }).then((r) => r.json())) as { id: string };
40 |
41 | const compositeKey = encodeCompositeKey(LATEST_KEY_VERSION, id, key);
42 |
43 | const url = new URL(window.location.href);
44 | url.pathname = "/unseal";
45 | url.hash = compositeKey;
46 | setCopied(false);
47 | setLink(url.toString());
48 | } catch (e) {
49 | console.error(e);
50 | setError((e as Error).message);
51 | } finally {
52 | setLoading(false);
53 | }
54 | };
55 |
56 | return (
57 |
58 | {error ?
: null}
59 |
60 | {link ? (
61 |
62 |
Share this link with others
63 |
64 |
65 | {link}
66 |
67 |
{
71 | navigator.clipboard.writeText(link);
72 | setCopied(true);
73 | }}
74 | >
75 | {copied ? (
76 |
77 | ) : (
78 |
79 | )}{" "}
80 | {copied ? "Copied" : "Copy"}
81 |
82 |
83 |
84 | ) : (
85 |
224 | )}
225 |
226 | );
227 | }
228 |
--------------------------------------------------------------------------------
/app/unseal/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { Fragment, useState, useEffect } from "react";
3 | import { ClipboardDocumentCheckIcon, ClipboardDocumentIcon, Cog6ToothIcon } from "@heroicons/react/24/outline";
4 |
5 | import { Title } from "@components/title";
6 |
7 | import { decodeCompositeKey } from "pkg/encoding";
8 | import { decrypt } from "pkg/encryption";
9 | import Link from "next/link";
10 | import { ErrorMessage } from "@components/error";
11 |
12 | export default function Unseal() {
13 | const [compositeKey, setCompositeKey] = useState("");
14 | useEffect(() => {
15 | if (typeof window !== "undefined") {
16 | setCompositeKey(window.location.hash.replace(/^#/, ""));
17 | }
18 | }, []);
19 |
20 | const [text, setText] = useState(null);
21 | const [loading, setLoading] = useState(false);
22 | const [remainingReads, setRemainingReads] = useState(null);
23 | const [error, setError] = useState(null);
24 | const [copied, setCopied] = useState(false);
25 |
26 | const onSubmit = async () => {
27 | try {
28 | setError(null);
29 | setText(null);
30 | setLoading(true);
31 |
32 | if (!compositeKey) {
33 | throw new Error("No id provided");
34 | }
35 |
36 | const { id, encryptionKey, version } = decodeCompositeKey(compositeKey);
37 | const res = await fetch(`/api/v1/load?id=${id}`);
38 | if (!res.ok) {
39 | throw new Error(await res.text());
40 | }
41 | const json = (await res.json()) as {
42 | iv: string;
43 | encrypted: string;
44 | remainingReads: number | null;
45 | };
46 | setRemainingReads(json.remainingReads);
47 |
48 | const decrypted = await decrypt(json.encrypted, encryptionKey, json.iv, version);
49 |
50 | setText(decrypted);
51 | } catch (e) {
52 | console.error(e);
53 | setError((e as Error).message);
54 | } finally {
55 | setLoading(false);
56 | }
57 | };
58 |
59 | return (
60 |
61 | {error ?
: null}
62 | {text ? (
63 |
64 | {remainingReads !== null ? (
65 |
66 | {remainingReads > 0 ? (
67 |
68 | This document can be read {remainingReads} more times.
69 |
70 | ) : (
71 |
72 | This was the last time this document could be read. It was deleted from storage.
73 |
74 | )}
75 |
76 | ) : null}
77 |
78 |
79 |
80 | {Array.from({
81 | length: text.split("\n").length,
82 | }).map((_, index) => (
83 |
84 | {(index + 1).toString().padStart(2, "0")}
85 |
86 |
87 | ))}
88 |
89 |
90 |
91 | {text}
92 |
93 |
94 |
95 |
96 |
97 |
98 |
103 | Share another
104 |
105 | {
109 | navigator.clipboard.writeText(text);
110 | setCopied(true);
111 | }}
112 | >
113 | {copied ? (
114 |
115 | ) : (
116 |
117 | )}{" "}
118 | {copied ? "Copied" : "Copy"}
119 |
120 |
121 |
122 | ) : (
123 |
{
126 | e.preventDefault();
127 | onSubmit();
128 | }}
129 | >
130 | Decrypt a document
131 |
132 |
133 |
134 | ID
135 |
136 | setCompositeKey(e.target.value)}
143 | />
144 |
145 |
146 |
153 | {loading ? : "Unseal"}
154 |
155 |
156 | )}
157 |
158 | );
159 | }
160 |
--------------------------------------------------------------------------------
/img/envshare.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chronark/envshare/a21b118364ccf537c6d2ccb6f26ba7210da72573/img/envshare.png
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | testEnvironment: "node",
5 | injectGlobals: false,
6 | };
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | appDir: true,
5 | },
6 | images: {
7 | domains: ["twitter.com", "pbs.twimg.com"],
8 | },
9 | };
10 |
11 | module.exports = nextConfig;
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "envshare",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "test": "jest --collect-coverage",
11 | "fmt": "pnpm rome check . --apply-suggested && pnpm rome format . --write"
12 | },
13 | "dependencies": {
14 | "@heroicons/react": "^2.0.13",
15 | "@next/font": "13.1.1",
16 | "@tailwindcss/forms": "^0.5.3",
17 | "@types/node": "18.11.18",
18 | "@types/react": "18.0.26",
19 | "@types/react-dom": "18.0.10",
20 | "@upstash/redis": "^1.19.1",
21 | "@vercel/analytics": "0.1.7-beta.1",
22 | "@vercel/og": "^0.0.27",
23 | "base-x": "^4.0.0",
24 | "eslint": "8.31.0",
25 | "eslint-config-next": "13.1.1",
26 | "next": "13.1.1",
27 | "react": "18.2.0",
28 | "react-dom": "18.2.0",
29 | "typescript": "4.9.4",
30 | "zod": "^3.20.2"
31 | },
32 | "devDependencies": {
33 | "@jest/globals": "^29.3.1",
34 | "@types/jest": "^29.2.5",
35 | "autoprefixer": "^10.4.13",
36 | "jest": "^29.3.1",
37 | "postcss": "^8.4.21",
38 | "rome": "^11.0.0",
39 | "tailwindcss": "^3.2.4",
40 | "ts-jest": "^29.0.5"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/pages/api/v1/load.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { Redis } from "@upstash/redis";
3 |
4 | const redis = Redis.fromEnv();
5 | export default async function handler(req: NextRequest) {
6 | const url = new URL(req.url);
7 | const id = url.searchParams.get("id");
8 | if (!id) {
9 | return new NextResponse("id param is missing", { status: 400 });
10 | }
11 | const key = ["envshare", id].join(":");
12 |
13 | const [data, _] = await Promise.all([
14 | await redis.hgetall<{ encrypted: string; remainingReads: number | null; iv: string }>(key),
15 | await redis.incr("envshare:metrics:reads"),
16 | ]);
17 | if (!data) {
18 | return new NextResponse("Not Found", { status: 404 });
19 | }
20 | if (data.remainingReads !== null && data.remainingReads < 1) {
21 | await redis.del(key);
22 | return new NextResponse("Not Found", { status: 404 });
23 | }
24 |
25 | let remainingReads: number | null = null;
26 | if (data.remainingReads !== null) {
27 | // Decrement the number of reads and return the remaining reads
28 | remainingReads = await redis.hincrby(key, "remainingReads", -1);
29 | }
30 |
31 | return NextResponse.json({ iv: data.iv, encrypted: data.encrypted, remainingReads });
32 | }
33 |
34 | export const config = {
35 | runtime: "edge",
36 | };
37 |
--------------------------------------------------------------------------------
/pages/api/v1/og.tsx:
--------------------------------------------------------------------------------
1 | import { ImageResponse } from "@vercel/og";
2 | import { NextRequest } from "next/server";
3 |
4 | export const config = {
5 | runtime: "edge",
6 | };
7 |
8 | export default async function handler(req: NextRequest) {
9 | try {
10 | const { searchParams } = new URL(req.url);
11 | // Redundant fallback alternate tagline
12 | const title = searchParams.get("title") ?? "Share Environment Variables Securely";
13 | const subtitle = searchParams.get("subtitle") ?? "EnvShare";
14 |
15 | const inter = await fetch(new URL("../../../public/fonts/Inter-SemiBold.ttf", import.meta.url)).then((res) =>
16 | res.arrayBuffer(),
17 | );
18 |
19 | // TODO: Fix tailwind classes on this route
20 | return new ImageResponse(
21 |
22 | {/* backgroundImage: bg-gradient-to-tr from-zinc-900/50 to-zinc-700/30 */}
23 |
27 |
28 | {/* font-semibold bg-gradient-to-t bg-clip-text from-zinc-100/50 to-white whitespace-pre */}
29 |
39 | {title}
40 |
41 |
{subtitle}
42 |
43 |
44 |
,
45 | {
46 | height: 630,
47 | width: 1200,
48 | emoji: "twemoji",
49 | fonts: [
50 | {
51 | name: "Inter",
52 | data: inter,
53 | style: "normal",
54 | },
55 | ],
56 | },
57 | );
58 | } catch (e) {
59 | console.log(`${(e as Error).message}`);
60 | return new Response("Failed to generate the image", {
61 | status: 500,
62 | });
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/pages/api/v1/secret/[id].ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { Redis } from "@upstash/redis";
3 | import { z } from "zod";
4 |
5 | const responseValidation = z.union([
6 | z.object({
7 | data: z.object({
8 | remainingReads: z.number().int().optional(),
9 | secret: z.string(),
10 | }),
11 | }),
12 | z.object({
13 | error: z.string(),
14 | }),
15 | ]);
16 |
17 | const redis = Redis.fromEnv();
18 | export default async function handler(req: NextRequest): Promise {
19 | try {
20 | if (req.method !== "GET") {
21 | return NextResponse.json({ error: "Method Not Allowed" }, { status: 405 });
22 | }
23 | const id = new URL(req.url).searchParams.get("id");
24 | if (!id) {
25 | return NextResponse.json({ error: "Missing `id` parameter" }, { status: 400 });
26 | }
27 |
28 | const redisKey = ["envshare", id].join(":");
29 |
30 | const [data, _] = await Promise.all([
31 | await redis.hgetall<{ secret: string; remainingReads: number | null }>(redisKey),
32 | await redis.incr("envshare:metrics:reads"),
33 | ]);
34 |
35 | if (!data) {
36 | return NextResponse.json({ error: "Not Found" }, { status: 404 });
37 | }
38 | if (data.remainingReads !== null && data.remainingReads < 1) {
39 | await redis.del(redisKey);
40 | return NextResponse.json({ error: "Not Found" }, { status: 404 });
41 | }
42 |
43 | let remainingReads: number | null = null;
44 | if (data.remainingReads !== null) {
45 | // Decrement the number of reads and return the remaining reads
46 | remainingReads = await redis.hincrby(redisKey, "remainingReads", -1);
47 | }
48 |
49 | return NextResponse.json({ data: { secret: data.secret, remainingReads: remainingReads ?? undefined } });
50 | } catch (e) {
51 | console.error(e);
52 | return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
53 | }
54 | }
55 |
56 | export const config = {
57 | runtime: "edge",
58 | };
59 |
--------------------------------------------------------------------------------
/pages/api/v1/secret/index.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { Redis } from "@upstash/redis";
3 | import { generateId } from "pkg/id";
4 | import { z } from "zod";
5 |
6 | export const requestValidation = z.object({
7 | // ttl in seconds
8 | // defaults to 30 days
9 | // not more than 1 year
10 | // 0 means no expiration
11 | ttl: z
12 | .string()
13 | .nullable()
14 | .transform((v) => (v ? parseInt(v, 10) : 43260))
15 | .refine((v) => v >= 0 && v <= 30758400, "ttl must be between 0 and 30758400 seconds"),
16 |
17 | // number of reads before deletion
18 | // defaults to null (no limit)
19 | reads: z
20 | .string()
21 | .nullable()
22 | .transform((v) => (v ? parseInt(v, 10) : null))
23 | .refine((v) => v === null || v > 0, "reads must be greater than 0"),
24 | secret: z.string().min(1),
25 | });
26 | export const responseValidation = z.union([
27 | z.object({
28 | data: z.object({
29 | id: z.string(),
30 | ttl: z.number().optional(),
31 | reads: z.number().optional(),
32 | expiresAt: z.string(),
33 | url: z.string().url(),
34 | }),
35 | }),
36 | z.object({
37 | error: z.string(),
38 | }),
39 | ]);
40 |
41 | const redis = Redis.fromEnv();
42 |
43 | export default async function handler(req: NextRequest): Promise {
44 | try {
45 | if (req.method !== "POST") {
46 | return NextResponse.json({ error: "Method Not Allowed" }, { status: 405 });
47 | }
48 |
49 | const parsed = requestValidation.safeParse({
50 | ttl: req.headers.get("envshare-ttl"),
51 | reads: req.headers.get("envshare-reads"),
52 | secret: await req.text(),
53 | });
54 | if (!parsed.success) {
55 | return NextResponse.json({ error: JSON.parse(parsed.error.message) }, { status: 400 });
56 | }
57 | const { ttl, reads, secret } = parsed.data;
58 |
59 | const id = generateId();
60 | const rediskey = ["envshare", id].join(":");
61 |
62 | const tx = redis.multi();
63 |
64 | tx.hset(rediskey, {
65 | remainingReads: reads ?? null,
66 | secret,
67 | });
68 | tx.incr("envshare:metrics:writes");
69 | if (ttl > 0) {
70 | tx.expire(rediskey, ttl);
71 | }
72 |
73 | await tx.exec();
74 | const url = new URL(req.url);
75 | url.pathname = `/api/v1/secret/${id}`;
76 |
77 | return NextResponse.json(
78 | responseValidation.parse({
79 | data: {
80 | id,
81 | ttl: ttl > 0 ? ttl : undefined,
82 | reads: reads ?? undefined,
83 | expiresAt: ttl > 0 ? new Date(Date.now() + ttl * 1000).toISOString() : undefined,
84 | url: url.toString(),
85 | },
86 | }),
87 | );
88 | } catch (e) {
89 | console.error(e);
90 | return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
91 | }
92 | }
93 |
94 | export const config = {
95 | runtime: "edge",
96 | };
97 |
--------------------------------------------------------------------------------
/pages/api/v1/store.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { Redis } from "@upstash/redis";
3 | import { generateId } from "pkg/id";
4 |
5 | type Request = {
6 | encrypted: string;
7 | ttl?: number;
8 | reads: number;
9 | iv: string;
10 | };
11 |
12 | const redis = Redis.fromEnv();
13 | export default async function handler(req: NextRequest) {
14 | const { encrypted, ttl, reads, iv } = (await req.json()) as Request;
15 |
16 | const id = generateId();
17 | const key = ["envshare", id].join(":");
18 |
19 | const tx = redis.multi();
20 |
21 | tx.hset(key, {
22 | remainingReads: reads > 0 ? reads : null,
23 | encrypted,
24 | iv,
25 | });
26 | if (ttl) {
27 | tx.expire(key, ttl);
28 | }
29 | tx.incr("envshare:metrics:writes");
30 |
31 | await tx.exec();
32 |
33 | return NextResponse.json({ id });
34 | }
35 |
36 | export const config = {
37 | runtime: "edge",
38 | };
39 |
--------------------------------------------------------------------------------
/pkg/constants.ts:
--------------------------------------------------------------------------------
1 | export const ID_LENGTH = 16;
2 | export const ENCRYPTION_KEY_LENGTH = 128;
3 | export const LATEST_KEY_VERSION = 2;
4 |
--------------------------------------------------------------------------------
/pkg/encoding.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeAll } from "@jest/globals";
2 | import { decodeCompositeKey, encodeCompositeKey } from "./encoding";
3 | import { generateKey } from "./encryption";
4 | import { generateId } from "./id";
5 | import crypto from "node:crypto";
6 |
7 | beforeAll(() => {
8 | global.crypto = crypto.webcrypto;
9 | });
10 | describe("composite key encoding", () => {
11 | it("encodes and decodes composite keys", async () => {
12 | for (let i = 0; i < 10000; i++) {
13 | const id = generateId();
14 | const key = new Uint8Array(await crypto.subtle.exportKey("raw", await generateKey()));
15 |
16 | const encoded = encodeCompositeKey(1, id, key);
17 |
18 | const decoded = decodeCompositeKey(encoded);
19 | expect(decoded.id).toEqual(id);
20 | expect(decoded.encryptionKey).toEqual(key);
21 | }
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/pkg/encoding.ts:
--------------------------------------------------------------------------------
1 | import { fromBase58, toBase58 } from "../util/base58";
2 | import { ID_LENGTH, ENCRYPTION_KEY_LENGTH } from "./constants";
3 | /**
4 | * To share links easily, we encode the id, where the data is stored in redis, together with the secret encryption key.
5 | */
6 | export function encodeCompositeKey(version: number, id: string, encryptionKey: Uint8Array): string {
7 | if (version < 0 || version > 255) {
8 | throw new Error("Version must fit in a byte");
9 | }
10 | const compositeKey = new Uint8Array([version, ...fromBase58(id), ...encryptionKey]);
11 |
12 | return toBase58(compositeKey);
13 | }
14 |
15 | /**
16 | * To share links easily, we encode the id, where the data is stored in redis, together with the secret encryption key.
17 | */
18 | export function decodeCompositeKey(compositeKey: string): { id: string; encryptionKey: Uint8Array; version: number } {
19 | const decoded = fromBase58(compositeKey);
20 | const version = decoded.at(0);
21 |
22 | if (version === 1 || version === 2) {
23 | return {
24 | id: toBase58(decoded.slice(1, 1 + ID_LENGTH)),
25 | encryptionKey: decoded.slice(1 + ID_LENGTH, 1 + ID_LENGTH + ENCRYPTION_KEY_LENGTH),
26 | version,
27 | };
28 | }
29 |
30 | throw new Error(`Unsupported composite key version: ${version}`);
31 | }
32 |
--------------------------------------------------------------------------------
/pkg/encryption.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeAll } from "@jest/globals";
2 | import { decrypt, encrypt } from "./encryption";
3 | import crypto from "node:crypto";
4 | import { toBase58 } from "../util/base58";
5 |
6 | beforeAll(() => {
7 | global.crypto = crypto.webcrypto;
8 | });
9 | describe("aes", () => {
10 | it("encrypts and decrypts correctly", async () => {
11 | for (let i = 0; i < 500; i++) {
12 | const buf = new Uint8Array(Math.ceil(Math.random() * 10 * i));
13 | crypto.getRandomValues(buf);
14 |
15 | const text = toBase58(buf);
16 |
17 | const { encrypted, key, iv } = await encrypt(text);
18 |
19 | const decrypted = await decrypt(toBase58(encrypted), key, toBase58(iv), 2);
20 |
21 | expect(decrypted).toEqual(text);
22 | }
23 | }, 30_000);
24 | });
25 |
--------------------------------------------------------------------------------
/pkg/encryption.ts:
--------------------------------------------------------------------------------
1 | import { fromBase58 } from "../util/base58";
2 |
3 | export async function generateKey() {
4 | return await crypto.subtle.generateKey(
5 | {
6 | name: "AES-GCM",
7 | length: 128,
8 | },
9 | true,
10 | ["encrypt", "decrypt"],
11 | );
12 | }
13 |
14 | export async function encrypt(text: string): Promise<{ encrypted: Uint8Array; iv: Uint8Array; key: Uint8Array }> {
15 | const key = await generateKey();
16 |
17 | const iv = crypto.getRandomValues(new Uint8Array(16));
18 |
19 | const encryptedBuffer = await crypto.subtle.encrypt(
20 | {
21 | name: "AES-GCM",
22 | iv,
23 | },
24 | key,
25 | new TextEncoder().encode(text),
26 | );
27 |
28 | const exportedKey = await crypto.subtle.exportKey("raw", key);
29 | return {
30 | encrypted: new Uint8Array(encryptedBuffer),
31 | key: new Uint8Array(exportedKey),
32 | iv,
33 | };
34 | }
35 |
36 | export async function decrypt(encrypted: string, keyData: Uint8Array, iv: string, keyVersion: number): Promise {
37 | const algorithm = keyVersion === 1 ? "AES-CBC" : "AES-GCM";
38 |
39 | const key = await crypto.subtle.importKey("raw", keyData, { name: algorithm, length: 128 }, false, ["decrypt"]);
40 |
41 | const decrypted = await crypto.subtle.decrypt(
42 | {
43 | name: algorithm,
44 | iv: fromBase58(iv),
45 | },
46 | key,
47 | fromBase58(encrypted),
48 | );
49 |
50 | return new TextDecoder().decode(decrypted);
51 | }
52 |
--------------------------------------------------------------------------------
/pkg/id.ts:
--------------------------------------------------------------------------------
1 | import { toBase58 } from "../util/base58";
2 | import { ID_LENGTH } from "./constants";
3 |
4 | export function generateId(): string {
5 | const bytes = new Uint8Array(ID_LENGTH);
6 | crypto.getRandomValues(bytes);
7 | return toBase58(bytes);
8 | }
9 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/fonts/Inter-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chronark/envshare/a21b118364ccf537c6d2ccb6f26ba7210da72573/public/fonts/Inter-SemiBold.ttf
--------------------------------------------------------------------------------
/rome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/rome/configuration_schema.json",
3 | "linter": {
4 | "enabled": true,
5 | "rules": {
6 | "recommended": true
7 | },
8 | "ignore": [
9 | "node_modules",
10 | ".next",
11 | "dist",
12 | ".turbo"
13 | ]
14 | },
15 | "formatter": {
16 | "enabled": true,
17 | "lineWidth": 120,
18 | "indentStyle": "space",
19 | "ignore": [
20 | "node_modules",
21 | ".next",
22 | "dist",
23 | ".turbo"
24 | ]
25 | }
26 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require("tailwindcss/defaultTheme");
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | content: ["./app/**/*.{js,ts,jsx,tsx}", "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
6 | theme: {
7 | fontFamily: {
8 | sans: ["var(--font-inter)", ...defaultTheme.fontFamily.sans],
9 | },
10 | extend: {
11 | dropShadow: {
12 | cta: ["0 10px 15px rgba(219, 227, 248, 0.2)"],
13 | blue: ["0 10px 15px rgba(59, 130, 246, 0.2)"],
14 | },
15 | },
16 | },
17 | plugins: [require("@tailwindcss/forms")],
18 | };
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "downlevelIteration": true,
18 | "baseUrl": ".",
19 | "paths": {
20 | "@components/*": ["./app/components/*"]
21 | },
22 | "plugins": [
23 | {
24 | "name": "next"
25 | }
26 | ]
27 | },
28 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
29 | "exclude": ["node_modules"]
30 | }
31 |
--------------------------------------------------------------------------------
/util/base58.ts:
--------------------------------------------------------------------------------
1 | import baseX from "base-x";
2 |
3 | const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
4 |
5 | export const toBase58 = (b: Uint8Array) => baseX(alphabet).encode(b);
6 |
7 | export const fromBase58 = (s: string) => baseX(alphabet).decode(s);
8 |
--------------------------------------------------------------------------------