@vitalpoint/near-phantom-auth 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # near-phantom-auth
2
+
3
+ Anonymous passkey authentication with NEAR MPC accounts and decentralized recovery.
4
+
5
+ > 🔒 **Privacy-first**: No email, no phone, no PII. Just biometrics and blockchain.
6
+
7
+ ## Features
8
+
9
+ - **Passkey Authentication**: Face ID, Touch ID, Windows Hello - no passwords
10
+ - **NEAR MPC Accounts**: User-owned accounts via Chain Signatures (8-node threshold MPC)
11
+ - **Anonymous Identity**: Codename-based (ALPHA-7, BRAVO-12) - we never know who you are
12
+ - **Decentralized Recovery**:
13
+ - Link a NEAR wallet (on-chain access key, not stored in our DB)
14
+ - Password + IPFS backup (encrypted, you hold the keys)
15
+ - **HttpOnly Sessions**: XSS-proof cookie-based sessions
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @vitalpoint/near-phantom-auth
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ### Server (Express)
26
+
27
+ ```typescript
28
+ import express from 'express';
29
+ import { createAnonAuth } from '@vitalpoint/near-phantom-auth/server';
30
+
31
+ const app = express();
32
+
33
+ const auth = createAnonAuth({
34
+ nearNetwork: 'testnet',
35
+ sessionSecret: process.env.SESSION_SECRET!,
36
+ database: {
37
+ type: 'postgres',
38
+ connectionString: process.env.DATABASE_URL!,
39
+ },
40
+ rp: {
41
+ name: 'My App',
42
+ id: 'myapp.com',
43
+ origin: 'https://myapp.com',
44
+ },
45
+ recovery: {
46
+ wallet: true,
47
+ ipfs: {
48
+ pinningService: 'pinata',
49
+ apiKey: process.env.PINATA_API_KEY,
50
+ apiSecret: process.env.PINATA_API_SECRET,
51
+ },
52
+ },
53
+ });
54
+
55
+ // Initialize database schema
56
+ await auth.initialize();
57
+
58
+ // Mount auth routes
59
+ app.use('/auth', auth.router);
60
+
61
+ // Protect routes
62
+ app.get('/api/me', auth.requireAuth, (req, res) => {
63
+ res.json({
64
+ codename: req.anonUser!.codename,
65
+ nearAccountId: req.anonUser!.nearAccountId,
66
+ });
67
+ });
68
+
69
+ app.listen(3000);
70
+ ```
71
+
72
+ ### Client (React)
73
+
74
+ ```tsx
75
+ import { AnonAuthProvider, useAnonAuth } from '@vitalpoint/near-phantom-auth/client';
76
+
77
+ function App() {
78
+ return (
79
+ <AnonAuthProvider apiUrl="/auth">
80
+ <AuthDemo />
81
+ </AnonAuthProvider>
82
+ );
83
+ }
84
+
85
+ function AuthDemo() {
86
+ const {
87
+ isLoading,
88
+ isAuthenticated,
89
+ codename,
90
+ register,
91
+ login,
92
+ logout,
93
+ error,
94
+ } = useAnonAuth();
95
+
96
+ if (isLoading) return <div>Loading...</div>;
97
+
98
+ if (!isAuthenticated) {
99
+ return (
100
+ <div>
101
+ <h1>Anonymous Auth Demo</h1>
102
+ {error && <p style={{ color: 'red' }}>{error}</p>}
103
+ <button onClick={register}>Register (Create Identity)</button>
104
+ <button onClick={() => login()}>Sign In (Existing Identity)</button>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <div>
111
+ <h1>Welcome, {codename}</h1>
112
+ <p>You are authenticated anonymously.</p>
113
+ <button onClick={logout}>Sign Out</button>
114
+ </div>
115
+ );
116
+ }
117
+ ```
118
+
119
+ ## How It Works
120
+
121
+ ### Registration Flow
122
+
123
+ ```
124
+ 1. User clicks "Register"
125
+ 2. Browser creates passkey (biometric prompt)
126
+ 3. Server creates NEAR account via MPC
127
+ 4. User gets codename (e.g., ALPHA-7)
128
+ 5. Session cookie set (HttpOnly, Secure, SameSite=Strict)
129
+ ```
130
+
131
+ ### Authentication Flow
132
+
133
+ ```
134
+ 1. User clicks "Sign In"
135
+ 2. Browser prompts for passkey (biometric)
136
+ 3. Server verifies signature
137
+ 4. Session cookie set
138
+ ```
139
+
140
+ ### Recovery Options
141
+
142
+ #### Wallet Recovery
143
+ - User links existing NEAR wallet
144
+ - Wallet added as on-chain access key (NOT stored in our database)
145
+ - Recovery: Sign with wallet → Create new passkey
146
+
147
+ #### Password + IPFS Recovery
148
+ - User sets strong password
149
+ - Recovery data encrypted with password
150
+ - Encrypted blob stored on IPFS
151
+ - User saves: password + IPFS CID
152
+ - Recovery: Provide password + CID → Decrypt → Create new passkey
153
+
154
+ ## API Routes
155
+
156
+ | Method | Route | Description |
157
+ |--------|-------|-------------|
158
+ | POST | `/register/start` | Start passkey registration |
159
+ | POST | `/register/finish` | Complete registration |
160
+ | POST | `/login/start` | Start authentication |
161
+ | POST | `/login/finish` | Complete authentication |
162
+ | POST | `/logout` | End session |
163
+ | GET | `/session` | Get current session |
164
+ | POST | `/recovery/wallet/link` | Start wallet linking |
165
+ | POST | `/recovery/wallet/verify` | Complete wallet linking |
166
+ | POST | `/recovery/wallet/start` | Start wallet recovery |
167
+ | POST | `/recovery/wallet/finish` | Complete wallet recovery |
168
+ | POST | `/recovery/ipfs/setup` | Create IPFS backup |
169
+ | POST | `/recovery/ipfs/recover` | Recover from IPFS |
170
+
171
+ ## Configuration
172
+
173
+ ### Database Adapters
174
+
175
+ Currently supports PostgreSQL. SQLite and custom adapters coming soon.
176
+
177
+ ```typescript
178
+ // PostgreSQL
179
+ database: {
180
+ type: 'postgres',
181
+ connectionString: 'postgresql://...',
182
+ }
183
+
184
+ // Custom adapter
185
+ database: {
186
+ type: 'custom',
187
+ adapter: myCustomAdapter,
188
+ }
189
+ ```
190
+
191
+ ### Codename Styles
192
+
193
+ ```typescript
194
+ codename: {
195
+ style: 'nato-phonetic', // ALPHA-7, BRAVO-12
196
+ // or
197
+ style: 'animals', // SWIFT-FALCON-42
198
+ // or
199
+ generator: (userId) => `SOURCE-${userId.slice(0, 8)}`,
200
+ }
201
+ ```
202
+
203
+ ### Recovery Options
204
+
205
+ ```typescript
206
+ recovery: {
207
+ // On-chain wallet recovery
208
+ wallet: true,
209
+
210
+ // IPFS + password recovery
211
+ ipfs: {
212
+ pinningService: 'pinata', // or 'web3storage', 'infura'
213
+ apiKey: '...',
214
+ apiSecret: '...',
215
+ // or custom functions
216
+ customPin: async (data) => cidString,
217
+ customFetch: async (cid) => Uint8Array,
218
+ },
219
+ }
220
+ ```
221
+
222
+ ## Security Model
223
+
224
+ ### What We Store
225
+
226
+ | Data | Stored? | Location |
227
+ |------|---------|----------|
228
+ | Email | ❌ | - |
229
+ | Phone | ❌ | - |
230
+ | Real name | ❌ | - |
231
+ | IP address | ❌ | - |
232
+ | Codename | ✅ | Database |
233
+ | NEAR account | ✅ | Database + Blockchain |
234
+ | Passkey public key | ✅ | Database |
235
+ | Recovery wallet link | ❌ | On-chain only |
236
+ | IPFS CID | ✅ | Database (encrypted content on IPFS) |
237
+
238
+ ### What We Cannot Know
239
+
240
+ - Real identity of users
241
+ - Link between codename and recovery wallet (it's on-chain, not in our DB)
242
+ - Contents of IPFS backup (encrypted with user's password)
243
+
244
+ ## License
245
+
246
+ MIT
247
+
248
+ ## Contributing
249
+
250
+ Contributions welcome! Please read our contributing guidelines first.
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var jsxRuntime = require('react/jsx-runtime');
5
+
6
+ // src/client/hooks/useAnonAuth.tsx
7
+
8
+ // src/client/api.ts
9
+ function createApiClient(config) {
10
+ const fetchFn = config.fetch || fetch;
11
+ const baseUrl = config.baseUrl.replace(/\/$/, "");
12
+ async function request(method, path, body) {
13
+ const response = await fetchFn(`${baseUrl}${path}`, {
14
+ method,
15
+ headers: {
16
+ "Content-Type": "application/json"
17
+ },
18
+ credentials: "include",
19
+ // Include cookies
20
+ body: body ? JSON.stringify(body) : void 0
21
+ });
22
+ if (!response.ok) {
23
+ const error = await response.json().catch(() => ({ error: "Request failed" }));
24
+ throw new Error(error.error || `Request failed: ${response.status}`);
25
+ }
26
+ return response.json();
27
+ }
28
+ return {
29
+ // Registration
30
+ async startRegistration() {
31
+ return request("POST", "/register/start");
32
+ },
33
+ async finishRegistration(challengeId, response, tempUserId, codename) {
34
+ return request("POST", "/register/finish", {
35
+ challengeId,
36
+ response,
37
+ tempUserId,
38
+ codename
39
+ });
40
+ },
41
+ // Authentication
42
+ async startAuthentication(codename) {
43
+ return request("POST", "/login/start", { codename });
44
+ },
45
+ async finishAuthentication(challengeId, response) {
46
+ return request("POST", "/login/finish", {
47
+ challengeId,
48
+ response
49
+ });
50
+ },
51
+ // Session
52
+ async getSession() {
53
+ try {
54
+ return await request("GET", "/session");
55
+ } catch {
56
+ return { authenticated: false };
57
+ }
58
+ },
59
+ async logout() {
60
+ await request("POST", "/logout");
61
+ },
62
+ // Wallet Recovery
63
+ async startWalletLink() {
64
+ return request("POST", "/recovery/wallet/link");
65
+ },
66
+ async finishWalletLink(signature, challenge, walletAccountId) {
67
+ return request("POST", "/recovery/wallet/verify", {
68
+ signature,
69
+ challenge,
70
+ walletAccountId
71
+ });
72
+ },
73
+ async startWalletRecovery() {
74
+ return request("POST", "/recovery/wallet/start");
75
+ },
76
+ async finishWalletRecovery(signature, challenge, nearAccountId) {
77
+ return request("POST", "/recovery/wallet/finish", {
78
+ signature,
79
+ challenge,
80
+ nearAccountId
81
+ });
82
+ },
83
+ // IPFS Recovery
84
+ async setupIPFSRecovery(password) {
85
+ return request("POST", "/recovery/ipfs/setup", { password });
86
+ },
87
+ async recoverFromIPFS(cid, password) {
88
+ return request("POST", "/recovery/ipfs/recover", { cid, password });
89
+ }
90
+ };
91
+ }
92
+
93
+ // src/client/passkey.ts
94
+ function isWebAuthnSupported() {
95
+ return typeof window !== "undefined" && typeof window.PublicKeyCredential !== "undefined" && typeof window.navigator.credentials !== "undefined";
96
+ }
97
+ async function isPlatformAuthenticatorAvailable() {
98
+ if (!isWebAuthnSupported()) return false;
99
+ try {
100
+ return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+ function base64urlToBuffer(base64url) {
106
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
107
+ const padLen = (4 - base64.length % 4) % 4;
108
+ const padded = base64 + "=".repeat(padLen);
109
+ const binary = atob(padded);
110
+ const bytes = new Uint8Array(binary.length);
111
+ for (let i = 0; i < binary.length; i++) {
112
+ bytes[i] = binary.charCodeAt(i);
113
+ }
114
+ return bytes.buffer;
115
+ }
116
+ function bufferToBase64url(buffer) {
117
+ const bytes = new Uint8Array(buffer);
118
+ let binary = "";
119
+ for (const byte of bytes) {
120
+ binary += String.fromCharCode(byte);
121
+ }
122
+ const base64 = btoa(binary);
123
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
124
+ }
125
+ async function createPasskey(options) {
126
+ if (!isWebAuthnSupported()) {
127
+ throw new Error("WebAuthn is not supported in this browser");
128
+ }
129
+ const publicKeyOptions = {
130
+ challenge: base64urlToBuffer(options.challenge),
131
+ rp: options.rp,
132
+ user: {
133
+ id: base64urlToBuffer(options.user.id),
134
+ name: options.user.name,
135
+ displayName: options.user.displayName
136
+ },
137
+ pubKeyCredParams: options.pubKeyCredParams,
138
+ timeout: options.timeout,
139
+ authenticatorSelection: options.authenticatorSelection,
140
+ attestation: options.attestation || "none",
141
+ excludeCredentials: options.excludeCredentials?.map((cred) => ({
142
+ id: base64urlToBuffer(cred.id),
143
+ type: cred.type,
144
+ transports: cred.transports
145
+ }))
146
+ };
147
+ const credential = await navigator.credentials.create({
148
+ publicKey: publicKeyOptions
149
+ });
150
+ if (!credential) {
151
+ throw new Error("Credential creation failed");
152
+ }
153
+ const response = credential.response;
154
+ return {
155
+ id: credential.id,
156
+ rawId: bufferToBase64url(credential.rawId),
157
+ type: "public-key",
158
+ response: {
159
+ clientDataJSON: bufferToBase64url(response.clientDataJSON),
160
+ attestationObject: bufferToBase64url(response.attestationObject),
161
+ transports: response.getTransports?.()
162
+ },
163
+ clientExtensionResults: credential.getClientExtensionResults()
164
+ };
165
+ }
166
+ async function authenticateWithPasskey(options) {
167
+ if (!isWebAuthnSupported()) {
168
+ throw new Error("WebAuthn is not supported in this browser");
169
+ }
170
+ const publicKeyOptions = {
171
+ challenge: base64urlToBuffer(options.challenge),
172
+ timeout: options.timeout,
173
+ rpId: options.rpId,
174
+ userVerification: options.userVerification,
175
+ allowCredentials: options.allowCredentials?.map((cred) => ({
176
+ id: base64urlToBuffer(cred.id),
177
+ type: cred.type,
178
+ transports: cred.transports
179
+ }))
180
+ };
181
+ const credential = await navigator.credentials.get({
182
+ publicKey: publicKeyOptions
183
+ });
184
+ if (!credential) {
185
+ throw new Error("Authentication failed");
186
+ }
187
+ const response = credential.response;
188
+ return {
189
+ id: credential.id,
190
+ rawId: bufferToBase64url(credential.rawId),
191
+ type: "public-key",
192
+ response: {
193
+ clientDataJSON: bufferToBase64url(response.clientDataJSON),
194
+ authenticatorData: bufferToBase64url(response.authenticatorData),
195
+ signature: bufferToBase64url(response.signature),
196
+ userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : void 0
197
+ },
198
+ clientExtensionResults: credential.getClientExtensionResults()
199
+ };
200
+ }
201
+ var AnonAuthContext = react.createContext(null);
202
+ function AnonAuthProvider({ apiUrl, children }) {
203
+ const [api] = react.useState(() => createApiClient({ baseUrl: apiUrl }));
204
+ const [state, setState] = react.useState({
205
+ isLoading: true,
206
+ isAuthenticated: false,
207
+ codename: null,
208
+ nearAccountId: null,
209
+ expiresAt: null,
210
+ webAuthnSupported: false,
211
+ platformAuthAvailable: false,
212
+ error: null
213
+ });
214
+ react.useEffect(() => {
215
+ const checkSupport = async () => {
216
+ const webAuthnSupported = isWebAuthnSupported();
217
+ const platformAuthAvailable = await isPlatformAuthenticatorAvailable();
218
+ setState((prev) => ({
219
+ ...prev,
220
+ webAuthnSupported,
221
+ platformAuthAvailable
222
+ }));
223
+ };
224
+ checkSupport();
225
+ }, []);
226
+ react.useEffect(() => {
227
+ const checkSession = async () => {
228
+ try {
229
+ const session = await api.getSession();
230
+ setState((prev) => ({
231
+ ...prev,
232
+ isLoading: false,
233
+ isAuthenticated: session.authenticated,
234
+ codename: session.codename || null,
235
+ nearAccountId: session.nearAccountId || null,
236
+ expiresAt: session.expiresAt ? new Date(session.expiresAt) : null
237
+ }));
238
+ } catch (error) {
239
+ setState((prev) => ({
240
+ ...prev,
241
+ isLoading: false,
242
+ error: error instanceof Error ? error.message : "Session check failed"
243
+ }));
244
+ }
245
+ };
246
+ checkSession();
247
+ }, [api]);
248
+ const register = react.useCallback(async () => {
249
+ try {
250
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
251
+ const { challengeId, options, tempUserId, codename } = await api.startRegistration();
252
+ const credential = await createPasskey(options);
253
+ const result = await api.finishRegistration(
254
+ challengeId,
255
+ credential,
256
+ tempUserId,
257
+ codename
258
+ );
259
+ if (result.success) {
260
+ setState((prev) => ({
261
+ ...prev,
262
+ isLoading: false,
263
+ isAuthenticated: true,
264
+ codename: result.codename,
265
+ nearAccountId: result.nearAccountId
266
+ }));
267
+ } else {
268
+ throw new Error("Registration failed");
269
+ }
270
+ } catch (error) {
271
+ setState((prev) => ({
272
+ ...prev,
273
+ isLoading: false,
274
+ error: error instanceof Error ? error.message : "Registration failed"
275
+ }));
276
+ }
277
+ }, [api]);
278
+ const login = react.useCallback(async (codename) => {
279
+ try {
280
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
281
+ const { challengeId, options } = await api.startAuthentication(codename);
282
+ const credential = await authenticateWithPasskey(options);
283
+ const result = await api.finishAuthentication(challengeId, credential);
284
+ if (result.success) {
285
+ const session = await api.getSession();
286
+ setState((prev) => ({
287
+ ...prev,
288
+ isLoading: false,
289
+ isAuthenticated: true,
290
+ codename: session.codename || result.codename,
291
+ nearAccountId: session.nearAccountId || null,
292
+ expiresAt: session.expiresAt ? new Date(session.expiresAt) : null
293
+ }));
294
+ } else {
295
+ throw new Error("Authentication failed");
296
+ }
297
+ } catch (error) {
298
+ setState((prev) => ({
299
+ ...prev,
300
+ isLoading: false,
301
+ error: error instanceof Error ? error.message : "Login failed"
302
+ }));
303
+ }
304
+ }, [api]);
305
+ const logout = react.useCallback(async () => {
306
+ try {
307
+ await api.logout();
308
+ setState((prev) => ({
309
+ ...prev,
310
+ isAuthenticated: false,
311
+ codename: null,
312
+ nearAccountId: null,
313
+ expiresAt: null
314
+ }));
315
+ } catch (error) {
316
+ setState((prev) => ({
317
+ ...prev,
318
+ error: error instanceof Error ? error.message : "Logout failed"
319
+ }));
320
+ }
321
+ }, [api]);
322
+ const refreshSession = react.useCallback(async () => {
323
+ try {
324
+ const session = await api.getSession();
325
+ setState((prev) => ({
326
+ ...prev,
327
+ isAuthenticated: session.authenticated,
328
+ codename: session.codename || null,
329
+ nearAccountId: session.nearAccountId || null,
330
+ expiresAt: session.expiresAt ? new Date(session.expiresAt) : null
331
+ }));
332
+ } catch (error) {
333
+ console.error("Session refresh failed:", error);
334
+ }
335
+ }, [api]);
336
+ const clearError = react.useCallback(() => {
337
+ setState((prev) => ({ ...prev, error: null }));
338
+ }, []);
339
+ const recovery = {
340
+ async linkWallet(signMessage, walletAccountId) {
341
+ const { challenge } = await api.startWalletLink();
342
+ const signature = await signMessage(challenge);
343
+ await api.finishWalletLink(signature, challenge, walletAccountId);
344
+ },
345
+ async recoverWithWallet(signMessage, nearAccountId) {
346
+ const { challenge } = await api.startWalletRecovery();
347
+ const signature = await signMessage(challenge);
348
+ const result = await api.finishWalletRecovery(signature, challenge, nearAccountId);
349
+ if (result.success) {
350
+ setState((prev) => ({
351
+ ...prev,
352
+ isAuthenticated: true,
353
+ codename: result.codename
354
+ }));
355
+ }
356
+ },
357
+ async setupPasswordRecovery(password) {
358
+ const result = await api.setupIPFSRecovery(password);
359
+ return { cid: result.cid };
360
+ },
361
+ async recoverWithPassword(cid, password) {
362
+ const result = await api.recoverFromIPFS(cid, password);
363
+ if (result.success) {
364
+ setState((prev) => ({
365
+ ...prev,
366
+ isAuthenticated: true,
367
+ codename: result.codename
368
+ }));
369
+ }
370
+ }
371
+ };
372
+ const value = {
373
+ ...state,
374
+ register,
375
+ login,
376
+ logout,
377
+ refreshSession,
378
+ clearError,
379
+ recovery
380
+ };
381
+ return /* @__PURE__ */ jsxRuntime.jsx(AnonAuthContext.Provider, { value, children });
382
+ }
383
+ function useAnonAuth() {
384
+ const context = react.useContext(AnonAuthContext);
385
+ if (!context) {
386
+ throw new Error("useAnonAuth must be used within AnonAuthProvider");
387
+ }
388
+ return context;
389
+ }
390
+
391
+ exports.AnonAuthProvider = AnonAuthProvider;
392
+ exports.authenticateWithPasskey = authenticateWithPasskey;
393
+ exports.createApiClient = createApiClient;
394
+ exports.createPasskey = createPasskey;
395
+ exports.isPlatformAuthenticatorAvailable = isPlatformAuthenticatorAvailable;
396
+ exports.isWebAuthnSupported = isWebAuthnSupported;
397
+ exports.useAnonAuth = useAnonAuth;
398
+ //# sourceMappingURL=index.cjs.map
399
+ //# sourceMappingURL=index.cjs.map