@darkauth/client 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/LICENSE +1 -0
- package/README.md +249 -0
- package/dist/crypto.d.ts +19 -0
- package/dist/crypto.js +109 -0
- package/dist/dek.d.ts +2 -0
- package/dist/dek.js +40 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.js +7 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +233 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# @DarkAuth/client
|
|
2
|
+
|
|
3
|
+
A TypeScript client library for DarkAuth - providing zero-knowledge authentication and client-side encryption capabilities for web applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Zero-Knowledge Authentication**: Secure OAuth2/OIDC flow with PKCE and ephemeral key exchange
|
|
8
|
+
- **Client-Side Encryption**: Built-in cryptographic functions for data encryption/decryption
|
|
9
|
+
- **Token Management**: Automatic token storage, validation, and refresh
|
|
10
|
+
- **Data Encryption Keys (DEK)**: Support for deriving and managing data encryption keys
|
|
11
|
+
- **Session Persistence**: Secure session storage with key obfuscation
|
|
12
|
+
- **TypeScript Support**: Full TypeScript definitions included
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @DarkAuth/client
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### Basic Setup
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { setConfig, initiateLogin, handleCallback, getStoredSession } from '@DarkAuth/client';
|
|
26
|
+
|
|
27
|
+
// Configure the client
|
|
28
|
+
setConfig({
|
|
29
|
+
issuer: 'https://auth.example.com',
|
|
30
|
+
clientId: 'your-client-id',
|
|
31
|
+
redirectUri: 'https://app.example.com/callback',
|
|
32
|
+
zk: true // Enable zero-knowledge mode
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Start login flow
|
|
36
|
+
await initiateLogin();
|
|
37
|
+
|
|
38
|
+
// Handle OAuth callback (on your callback page)
|
|
39
|
+
const session = await handleCallback();
|
|
40
|
+
if (session) {
|
|
41
|
+
console.log('Logged in!', session.idToken);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Get existing session
|
|
45
|
+
const existingSession = getStoredSession();
|
|
46
|
+
if (existingSession && isTokenValid(existingSession.idToken)) {
|
|
47
|
+
// User is authenticated
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API Reference
|
|
52
|
+
|
|
53
|
+
### Configuration
|
|
54
|
+
|
|
55
|
+
#### `setConfig(config: Partial<Config>)`
|
|
56
|
+
|
|
57
|
+
Configure the DarkAuth client with your authentication settings.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
setConfig({
|
|
61
|
+
issuer: 'https://auth.example.com', // DarkAuth server URL
|
|
62
|
+
clientId: 'your-client-id', // Your application's client ID
|
|
63
|
+
redirectUri: 'https://app.example.com/callback', // OAuth callback URL
|
|
64
|
+
zk: true // Enable zero-knowledge mode (default: true)
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The client also supports environment variables for configuration:
|
|
69
|
+
- `DARKAUTH_ISSUER` or `VITE_DARKAUTH_ISSUER`
|
|
70
|
+
- `DARKAUTH_CLIENT_ID` or `VITE_CLIENT_ID`
|
|
71
|
+
- `VITE_REDIRECT_URI`
|
|
72
|
+
|
|
73
|
+
### Authentication Functions
|
|
74
|
+
|
|
75
|
+
#### `initiateLogin(): Promise<void>`
|
|
76
|
+
|
|
77
|
+
Starts the OAuth2/OIDC login flow with PKCE. Redirects the user to the DarkAuth authorization server.
|
|
78
|
+
|
|
79
|
+
#### `handleCallback(): Promise<AuthSession | null>`
|
|
80
|
+
|
|
81
|
+
Processes the OAuth callback after successful authentication. Returns an `AuthSession` object containing:
|
|
82
|
+
- `idToken`: JWT ID token
|
|
83
|
+
- `drk`: Derived Root Key for encryption operations
|
|
84
|
+
- `refreshToken?`: Optional refresh token
|
|
85
|
+
|
|
86
|
+
#### `logout(): void`
|
|
87
|
+
|
|
88
|
+
Clears all authentication data from storage.
|
|
89
|
+
|
|
90
|
+
#### `getStoredSession(): AuthSession | null`
|
|
91
|
+
|
|
92
|
+
Retrieves the current session from storage if valid.
|
|
93
|
+
|
|
94
|
+
#### `refreshSession(): Promise<AuthSession | null>`
|
|
95
|
+
|
|
96
|
+
Refreshes the current session using the stored refresh token.
|
|
97
|
+
|
|
98
|
+
### User Information
|
|
99
|
+
|
|
100
|
+
#### `getCurrentUser(): JwtClaims | null`
|
|
101
|
+
|
|
102
|
+
Returns the parsed JWT claims from the current ID token.
|
|
103
|
+
|
|
104
|
+
#### `parseJwt(token: string): JwtClaims | null`
|
|
105
|
+
|
|
106
|
+
Parses a JWT token and returns its claims.
|
|
107
|
+
|
|
108
|
+
#### `isTokenValid(token: string): boolean`
|
|
109
|
+
|
|
110
|
+
Checks if a JWT token is still valid (not expired).
|
|
111
|
+
|
|
112
|
+
### Cryptographic Functions
|
|
113
|
+
|
|
114
|
+
The library exports comprehensive cryptographic utilities from `./crypto`:
|
|
115
|
+
|
|
116
|
+
#### Encoding/Decoding
|
|
117
|
+
- `bytesToBase64Url(bytes: Uint8Array): string`
|
|
118
|
+
- `base64UrlToBytes(base64url: string): Uint8Array`
|
|
119
|
+
- `bytesToBase64(bytes: Uint8Array): string`
|
|
120
|
+
- `base64ToBytes(base64: string): Uint8Array`
|
|
121
|
+
|
|
122
|
+
#### Hashing
|
|
123
|
+
- `sha256(bytes: Uint8Array): Promise<Uint8Array>`
|
|
124
|
+
|
|
125
|
+
#### Key Derivation
|
|
126
|
+
- `hkdf(key: Uint8Array, salt: Uint8Array, info: Uint8Array, length?: number): Promise<Uint8Array>`
|
|
127
|
+
- `deriveDek(drk: Uint8Array, noteId: string): Promise<Uint8Array>`
|
|
128
|
+
|
|
129
|
+
#### Encryption/Decryption
|
|
130
|
+
- `aeadEncrypt(key: CryptoKey, plaintext: Uint8Array, additionalData: Uint8Array): Promise<{iv: Uint8Array, ciphertext: Uint8Array}>`
|
|
131
|
+
- `aeadDecrypt(key: CryptoKey, payload: Uint8Array, additionalData: Uint8Array): Promise<Uint8Array>`
|
|
132
|
+
- `encryptNote(drk: Uint8Array, noteId: string, content: string): Promise<string>`
|
|
133
|
+
- `decryptNote(drk: Uint8Array, noteId: string, ciphertextBase64: string, aadObject: Record<string, unknown>): Promise<string>`
|
|
134
|
+
|
|
135
|
+
#### Key Management
|
|
136
|
+
- `wrapPrivateKey(privateKeyJwk: JsonWebKey, drk: Uint8Array): Promise<string>`
|
|
137
|
+
- `unwrapPrivateKey(wrappedKey: string, drk: Uint8Array): Promise<JsonWebKey>`
|
|
138
|
+
|
|
139
|
+
### Data Encryption Keys (DEK)
|
|
140
|
+
|
|
141
|
+
#### `resolveDek(noteId: string, isOwner: boolean, drk: Uint8Array): Promise<Uint8Array>`
|
|
142
|
+
|
|
143
|
+
Resolves a data encryption key for a specific resource. If the user is the owner, derives the DEK directly. Otherwise, fetches and decrypts the shared DEK.
|
|
144
|
+
|
|
145
|
+
#### `clearKeyCache(): void`
|
|
146
|
+
|
|
147
|
+
Clears the cached encryption keys.
|
|
148
|
+
|
|
149
|
+
### Hooks System
|
|
150
|
+
|
|
151
|
+
#### `setHooks(hooks: ClientHooks)`
|
|
152
|
+
|
|
153
|
+
Configure hooks for custom data fetching:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
setHooks({
|
|
157
|
+
fetchNoteDek: async (noteId: string) => {
|
|
158
|
+
// Fetch encrypted DEK for a shared note
|
|
159
|
+
const response = await fetch(`/api/notes/${noteId}/dek`);
|
|
160
|
+
return response.text();
|
|
161
|
+
},
|
|
162
|
+
fetchWrappedEncPrivateJwk: async () => {
|
|
163
|
+
// Fetch user's wrapped private key
|
|
164
|
+
const response = await fetch('/api/user/private-key');
|
|
165
|
+
return response.text();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Types
|
|
171
|
+
|
|
172
|
+
### `AuthSession`
|
|
173
|
+
```typescript
|
|
174
|
+
interface AuthSession {
|
|
175
|
+
idToken: string;
|
|
176
|
+
drk: Uint8Array;
|
|
177
|
+
refreshToken?: string;
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `JwtClaims`
|
|
182
|
+
```typescript
|
|
183
|
+
interface JwtClaims {
|
|
184
|
+
sub?: string;
|
|
185
|
+
email?: string;
|
|
186
|
+
name?: string;
|
|
187
|
+
exp?: number;
|
|
188
|
+
iat?: number;
|
|
189
|
+
iss?: string;
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### `Config`
|
|
194
|
+
```typescript
|
|
195
|
+
type Config = {
|
|
196
|
+
issuer: string;
|
|
197
|
+
clientId: string;
|
|
198
|
+
redirectUri: string;
|
|
199
|
+
zk?: boolean;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `ClientHooks`
|
|
204
|
+
```typescript
|
|
205
|
+
type ClientHooks = {
|
|
206
|
+
fetchNoteDek?: (noteId: string) => Promise<string>;
|
|
207
|
+
fetchWrappedEncPrivateJwk?: () => Promise<string>;
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Security Features
|
|
212
|
+
|
|
213
|
+
- **PKCE (Proof Key for Code Exchange)**: Protects against authorization code interception
|
|
214
|
+
- **Zero-Knowledge Mode**: Ephemeral key exchange for enhanced privacy
|
|
215
|
+
- **Key Obfuscation**: DRK is obfuscated in storage for additional protection
|
|
216
|
+
- **Secure Storage**: Uses sessionStorage for tokens and localStorage for persistent data
|
|
217
|
+
- **AEAD Encryption**: AES-GCM with additional authenticated data for all encryption operations
|
|
218
|
+
|
|
219
|
+
## Browser Compatibility
|
|
220
|
+
|
|
221
|
+
This library requires a modern browser with support for:
|
|
222
|
+
- Web Crypto API
|
|
223
|
+
- ES2015+ features
|
|
224
|
+
- SessionStorage and LocalStorage
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
# Install dependencies
|
|
230
|
+
npm install
|
|
231
|
+
|
|
232
|
+
# Build the package
|
|
233
|
+
npm run build
|
|
234
|
+
|
|
235
|
+
# Type checking
|
|
236
|
+
npm run typecheck
|
|
237
|
+
|
|
238
|
+
# Linting and formatting
|
|
239
|
+
npm run lint
|
|
240
|
+
npm run format
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
MIT
|
|
246
|
+
|
|
247
|
+
## Contributing
|
|
248
|
+
|
|
249
|
+
Contributions are welcome! Please ensure all code passes linting and type checking before submitting a pull request.
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function bytesToBase64Url(bytes: Uint8Array): string;
|
|
2
|
+
export declare function base64UrlToBytes(base64url: string): Uint8Array;
|
|
3
|
+
export declare function bytesToBase64(bytes: Uint8Array): string;
|
|
4
|
+
export declare function base64ToBytes(base64: string): Uint8Array;
|
|
5
|
+
export declare function sha256(bytes: Uint8Array): Promise<Uint8Array>;
|
|
6
|
+
export declare function hkdf(key: Uint8Array, salt: Uint8Array, info: Uint8Array, length?: number): Promise<Uint8Array>;
|
|
7
|
+
export declare function aeadEncrypt(key: CryptoKey, plaintext: Uint8Array, additionalData: Uint8Array): Promise<{
|
|
8
|
+
iv: Uint8Array;
|
|
9
|
+
ciphertext: Uint8Array;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function aeadDecrypt(key: CryptoKey, payload: Uint8Array, additionalData: Uint8Array): Promise<Uint8Array>;
|
|
12
|
+
export declare function aeadKey(bytes: Uint8Array): Promise<CryptoKey>;
|
|
13
|
+
export declare function deriveDek(drk: Uint8Array, noteId: string): Promise<Uint8Array>;
|
|
14
|
+
export declare function encryptNote(drk: Uint8Array, noteId: string, content: string): Promise<string>;
|
|
15
|
+
export declare function decryptNote(drk: Uint8Array, noteId: string, ciphertextBase64: string, aadObject: Record<string, unknown>): Promise<string>;
|
|
16
|
+
export declare function encryptNoteWithDek(dek: Uint8Array, noteId: string, content: string): Promise<string>;
|
|
17
|
+
export declare function decryptNoteWithDek(dek: Uint8Array, noteId: string, ciphertextBase64: string, aadObject: Record<string, unknown>): Promise<string>;
|
|
18
|
+
export declare function wrapPrivateKey(privateKeyJwk: JsonWebKey, drk: Uint8Array): Promise<string>;
|
|
19
|
+
export declare function unwrapPrivateKey(wrappedKey: string, drk: Uint8Array): Promise<JsonWebKey>;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export function bytesToBase64Url(bytes) {
|
|
2
|
+
let binaryString = "";
|
|
3
|
+
for (const byte of bytes)
|
|
4
|
+
binaryString += String.fromCharCode(byte);
|
|
5
|
+
return btoa(binaryString).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6
|
+
}
|
|
7
|
+
export function base64UrlToBytes(base64url) {
|
|
8
|
+
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
9
|
+
const padding = base64.length % 4 === 2 ? "==" : base64.length % 4 === 3 ? "=" : "";
|
|
10
|
+
return Uint8Array.from(atob(base64 + padding), (c) => c.charCodeAt(0));
|
|
11
|
+
}
|
|
12
|
+
export function bytesToBase64(bytes) {
|
|
13
|
+
let binaryString = "";
|
|
14
|
+
for (const byte of bytes)
|
|
15
|
+
binaryString += String.fromCharCode(byte);
|
|
16
|
+
return btoa(binaryString);
|
|
17
|
+
}
|
|
18
|
+
export function base64ToBytes(base64) {
|
|
19
|
+
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
20
|
+
}
|
|
21
|
+
export async function sha256(bytes) {
|
|
22
|
+
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
23
|
+
const digest = await crypto.subtle.digest("SHA-256", buf);
|
|
24
|
+
return new Uint8Array(digest);
|
|
25
|
+
}
|
|
26
|
+
export async function hkdf(key, salt, info, length = 32) {
|
|
27
|
+
const toAB = (u) => u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
|
|
28
|
+
const importedKey = await crypto.subtle.importKey("raw", toAB(key), "HKDF", false, [
|
|
29
|
+
"deriveBits",
|
|
30
|
+
]);
|
|
31
|
+
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: toAB(salt), info: toAB(info) }, importedKey, length * 8);
|
|
32
|
+
return new Uint8Array(bits);
|
|
33
|
+
}
|
|
34
|
+
export async function aeadEncrypt(key, plaintext, additionalData) {
|
|
35
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
36
|
+
const toAB = (u) => u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
|
|
37
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: toAB(additionalData) }, key, toAB(plaintext));
|
|
38
|
+
return { iv, ciphertext: new Uint8Array(ciphertext) };
|
|
39
|
+
}
|
|
40
|
+
export async function aeadDecrypt(key, payload, additionalData) {
|
|
41
|
+
const iv = payload.slice(0, 12);
|
|
42
|
+
const ciphertext = payload.slice(12);
|
|
43
|
+
const toAB = (u) => u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
|
|
44
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv, additionalData: toAB(additionalData) }, key, toAB(ciphertext));
|
|
45
|
+
return new Uint8Array(plaintext);
|
|
46
|
+
}
|
|
47
|
+
export async function aeadKey(bytes) {
|
|
48
|
+
const toAB = (u) => u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
|
|
49
|
+
return crypto.subtle.importKey("raw", toAB(bytes), { name: "AES-GCM" }, false, [
|
|
50
|
+
"encrypt",
|
|
51
|
+
"decrypt",
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
export async function deriveDek(drk, noteId) {
|
|
55
|
+
const salt = new TextEncoder().encode("DarkAuth|demo-notes");
|
|
56
|
+
const info = new TextEncoder().encode(`note:${noteId}`);
|
|
57
|
+
return hkdf(drk, salt, info, 32);
|
|
58
|
+
}
|
|
59
|
+
export async function encryptNote(drk, noteId, content) {
|
|
60
|
+
const dek = await deriveDek(drk, noteId);
|
|
61
|
+
return encryptNoteWithDek(dek, noteId, content);
|
|
62
|
+
}
|
|
63
|
+
export async function decryptNote(drk, noteId, ciphertextBase64, aadObject) {
|
|
64
|
+
const dek = await deriveDek(drk, noteId);
|
|
65
|
+
return decryptNoteWithDek(dek, noteId, ciphertextBase64, aadObject);
|
|
66
|
+
}
|
|
67
|
+
export async function encryptNoteWithDek(dek, noteId, content) {
|
|
68
|
+
const key = await aeadKey(dek);
|
|
69
|
+
const plaintext = new TextEncoder().encode(content);
|
|
70
|
+
const aad = new TextEncoder().encode(JSON.stringify({ note_id: noteId }));
|
|
71
|
+
const { iv, ciphertext } = await aeadEncrypt(key, plaintext, aad);
|
|
72
|
+
const payload = new Uint8Array(iv.length + ciphertext.length);
|
|
73
|
+
payload.set(iv, 0);
|
|
74
|
+
payload.set(ciphertext, iv.length);
|
|
75
|
+
return bytesToBase64(payload);
|
|
76
|
+
}
|
|
77
|
+
export async function decryptNoteWithDek(dek, noteId, ciphertextBase64, aadObject) {
|
|
78
|
+
if (noteId === "") {
|
|
79
|
+
// touch param to satisfy TS noUnusedParameters
|
|
80
|
+
}
|
|
81
|
+
const key = await aeadKey(dek);
|
|
82
|
+
const aad = new TextEncoder().encode(JSON.stringify(aadObject));
|
|
83
|
+
const payload = base64ToBytes(ciphertextBase64);
|
|
84
|
+
const plaintext = await aeadDecrypt(key, payload, aad);
|
|
85
|
+
return new TextDecoder().decode(plaintext);
|
|
86
|
+
}
|
|
87
|
+
export async function wrapPrivateKey(privateKeyJwk, drk) {
|
|
88
|
+
const salt = new TextEncoder().encode("DarkAuth|user-keys");
|
|
89
|
+
const info = new TextEncoder().encode("private-key-wrap");
|
|
90
|
+
const wrapKey = await hkdf(drk, salt, info, 32);
|
|
91
|
+
const key = await aeadKey(wrapKey);
|
|
92
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(privateKeyJwk));
|
|
93
|
+
const aad = new TextEncoder().encode("user-private-key");
|
|
94
|
+
const { iv, ciphertext } = await aeadEncrypt(key, plaintext, aad);
|
|
95
|
+
const payload = new Uint8Array(iv.length + ciphertext.length);
|
|
96
|
+
payload.set(iv, 0);
|
|
97
|
+
payload.set(ciphertext, iv.length);
|
|
98
|
+
return bytesToBase64Url(payload);
|
|
99
|
+
}
|
|
100
|
+
export async function unwrapPrivateKey(wrappedKey, drk) {
|
|
101
|
+
const salt = new TextEncoder().encode("DarkAuth|user-keys");
|
|
102
|
+
const info = new TextEncoder().encode("private-key-wrap");
|
|
103
|
+
const wrapKey = await hkdf(drk, salt, info, 32);
|
|
104
|
+
const key = await aeadKey(wrapKey);
|
|
105
|
+
const payload = base64UrlToBytes(wrappedKey);
|
|
106
|
+
const aad = new TextEncoder().encode("user-private-key");
|
|
107
|
+
const plaintext = await aeadDecrypt(key, payload, aad);
|
|
108
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
109
|
+
}
|
package/dist/dek.d.ts
ADDED
package/dist/dek.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { compactDecrypt, importJWK } from "jose";
|
|
2
|
+
import { deriveDek, unwrapPrivateKey } from "./crypto";
|
|
3
|
+
import { getHooks } from "./hooks";
|
|
4
|
+
let cachedPrivKey = null;
|
|
5
|
+
let cachedPrivKeyPromise = null;
|
|
6
|
+
async function getUserEncPrivateKey(drk) {
|
|
7
|
+
if (cachedPrivKey)
|
|
8
|
+
return cachedPrivKey;
|
|
9
|
+
if (cachedPrivKeyPromise)
|
|
10
|
+
return cachedPrivKeyPromise;
|
|
11
|
+
const hooks = getHooks();
|
|
12
|
+
if (!hooks.fetchWrappedEncPrivateJwk)
|
|
13
|
+
throw new Error("fetchWrappedEncPrivateJwk hook not set");
|
|
14
|
+
cachedPrivKeyPromise = (async () => {
|
|
15
|
+
const fetchWrapped = hooks.fetchWrappedEncPrivateJwk;
|
|
16
|
+
if (!fetchWrapped)
|
|
17
|
+
throw new Error("fetchWrappedEncPrivateJwk hook not set");
|
|
18
|
+
const wrapped = await fetchWrapped();
|
|
19
|
+
const jwk = await unwrapPrivateKey(wrapped, drk);
|
|
20
|
+
const key = await importJWK(jwk, "ECDH-ES");
|
|
21
|
+
cachedPrivKey = key;
|
|
22
|
+
return cachedPrivKey;
|
|
23
|
+
})();
|
|
24
|
+
return cachedPrivKeyPromise;
|
|
25
|
+
}
|
|
26
|
+
export async function resolveDek(noteId, isOwner, drk) {
|
|
27
|
+
if (isOwner)
|
|
28
|
+
return deriveDek(drk, noteId);
|
|
29
|
+
const hooks = getHooks();
|
|
30
|
+
if (!hooks.fetchNoteDek)
|
|
31
|
+
throw new Error("fetchNoteDek hook not set");
|
|
32
|
+
const jwe = await hooks.fetchNoteDek(noteId);
|
|
33
|
+
const priv = await getUserEncPrivateKey(drk);
|
|
34
|
+
const { plaintext } = await compactDecrypt(jwe, priv);
|
|
35
|
+
return new Uint8Array(plaintext);
|
|
36
|
+
}
|
|
37
|
+
export function clearKeyCache() {
|
|
38
|
+
cachedPrivKey = null;
|
|
39
|
+
cachedPrivKeyPromise = null;
|
|
40
|
+
}
|
package/dist/hooks.d.ts
ADDED
package/dist/hooks.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export * from "./crypto";
|
|
2
|
+
export * from "./dek";
|
|
3
|
+
export { setHooks } from "./hooks";
|
|
4
|
+
type Config = {
|
|
5
|
+
issuer: string;
|
|
6
|
+
clientId: string;
|
|
7
|
+
redirectUri: string;
|
|
8
|
+
zk?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export interface AuthSession {
|
|
11
|
+
idToken: string;
|
|
12
|
+
drk: Uint8Array;
|
|
13
|
+
refreshToken?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface JwtClaims {
|
|
16
|
+
sub?: string;
|
|
17
|
+
email?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
exp?: number;
|
|
20
|
+
iat?: number;
|
|
21
|
+
iss?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function setConfig(next: Partial<Config>): void;
|
|
24
|
+
export declare function parseJwt(token: string): JwtClaims | null;
|
|
25
|
+
export declare function isTokenValid(token: string): boolean;
|
|
26
|
+
export declare function initiateLogin(): Promise<void>;
|
|
27
|
+
export declare function handleCallback(): Promise<AuthSession | null>;
|
|
28
|
+
export declare function getStoredSession(): AuthSession | null;
|
|
29
|
+
export declare function refreshSession(): Promise<AuthSession | null>;
|
|
30
|
+
export declare function logout(): void;
|
|
31
|
+
export declare function getCurrentUser(): JwtClaims | null;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { compactDecrypt } from "jose";
|
|
2
|
+
export * from "./crypto";
|
|
3
|
+
export * from "./dek";
|
|
4
|
+
export { setHooks } from "./hooks";
|
|
5
|
+
function viteEnvGet(key) {
|
|
6
|
+
try {
|
|
7
|
+
const im = import.meta || undefined;
|
|
8
|
+
return im?.env?.[key];
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
let cfg = {
|
|
15
|
+
issuer: (typeof window !== "undefined" && window.__APP_CONFIG__?.issuer) ||
|
|
16
|
+
viteEnvGet("VITE_DARKAUTH_ISSUER") ||
|
|
17
|
+
(typeof process !== "undefined" ? process.env.DARKAUTH_ISSUER : undefined) ||
|
|
18
|
+
"http://localhost:9080",
|
|
19
|
+
clientId: (typeof window !== "undefined" && window.__APP_CONFIG__?.clientId) ||
|
|
20
|
+
viteEnvGet("VITE_CLIENT_ID") ||
|
|
21
|
+
(typeof process !== "undefined" ? process.env.DARKAUTH_CLIENT_ID : undefined) ||
|
|
22
|
+
"app-web",
|
|
23
|
+
redirectUri: (typeof window !== "undefined" && window.__APP_CONFIG__?.redirectUri) ||
|
|
24
|
+
viteEnvGet("VITE_REDIRECT_URI") ||
|
|
25
|
+
(typeof window !== "undefined"
|
|
26
|
+
? `${window.location.origin}/callback`
|
|
27
|
+
: "http://localhost:5173/callback"),
|
|
28
|
+
zk: true,
|
|
29
|
+
};
|
|
30
|
+
const OBFUSCATION_KEY = "DarkAuth-Storage-Protection-2025";
|
|
31
|
+
export function setConfig(next) {
|
|
32
|
+
cfg = { ...cfg, ...next };
|
|
33
|
+
}
|
|
34
|
+
function bytesToBase64Url(bytes) {
|
|
35
|
+
let s = "";
|
|
36
|
+
for (const b of bytes)
|
|
37
|
+
s += String.fromCharCode(b);
|
|
38
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
39
|
+
}
|
|
40
|
+
function base64UrlToBytes(base64url) {
|
|
41
|
+
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
42
|
+
const padding = base64.length % 4 === 2 ? "==" : base64.length % 4 === 3 ? "=" : "";
|
|
43
|
+
return Uint8Array.from(atob(base64 + padding), (c) => c.charCodeAt(0));
|
|
44
|
+
}
|
|
45
|
+
async function sha256(bytes) {
|
|
46
|
+
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
47
|
+
const d = await crypto.subtle.digest("SHA-256", buf);
|
|
48
|
+
return new Uint8Array(d);
|
|
49
|
+
}
|
|
50
|
+
function parseFragmentParams(hash) {
|
|
51
|
+
const res = {};
|
|
52
|
+
const h = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
53
|
+
for (const part of h.split("&")) {
|
|
54
|
+
const i = part.indexOf("=");
|
|
55
|
+
if (i === -1)
|
|
56
|
+
continue;
|
|
57
|
+
const k = decodeURIComponent(part.slice(0, i));
|
|
58
|
+
const v = decodeURIComponent(part.slice(i + 1));
|
|
59
|
+
res[k] = v;
|
|
60
|
+
}
|
|
61
|
+
return res;
|
|
62
|
+
}
|
|
63
|
+
function obfuscateKey(drk) {
|
|
64
|
+
const obfKey = new TextEncoder().encode(OBFUSCATION_KEY);
|
|
65
|
+
const out = new Uint8Array(drk.length);
|
|
66
|
+
for (let i = 0; i < drk.length; i++) {
|
|
67
|
+
const a = drk[i] ?? 0;
|
|
68
|
+
const b = obfKey[i % obfKey.length] ?? 0;
|
|
69
|
+
out[i] = a ^ b;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function deobfuscateKey(obfuscated) {
|
|
74
|
+
return obfuscateKey(obfuscated);
|
|
75
|
+
}
|
|
76
|
+
export function parseJwt(token) {
|
|
77
|
+
try {
|
|
78
|
+
const parts = token.split(".");
|
|
79
|
+
if (parts.length !== 3)
|
|
80
|
+
return null;
|
|
81
|
+
const mid = parts[1];
|
|
82
|
+
const payload = JSON.parse(atob(mid.replace(/-/g, "+").replace(/_/g, "/")));
|
|
83
|
+
return payload;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function isTokenValid(token) {
|
|
90
|
+
const claims = parseJwt(token);
|
|
91
|
+
if (!claims?.exp)
|
|
92
|
+
return false;
|
|
93
|
+
return claims.exp * 1000 > Date.now() + 5000;
|
|
94
|
+
}
|
|
95
|
+
export async function initiateLogin() {
|
|
96
|
+
const keyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
|
|
97
|
+
"deriveKey",
|
|
98
|
+
"deriveBits",
|
|
99
|
+
]);
|
|
100
|
+
const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
|
101
|
+
const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
|
|
102
|
+
sessionStorage.setItem("zk_eph_priv_jwk", JSON.stringify(privateJwk));
|
|
103
|
+
const zkPubParam = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(publicJwk)));
|
|
104
|
+
const state = crypto.randomUUID();
|
|
105
|
+
const verifier = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
106
|
+
sessionStorage.setItem("pkce_verifier", verifier);
|
|
107
|
+
const challenge = bytesToBase64Url(await sha256(new TextEncoder().encode(verifier)));
|
|
108
|
+
const authUrl = new URL("/authorize", cfg.issuer);
|
|
109
|
+
authUrl.searchParams.set("client_id", cfg.clientId);
|
|
110
|
+
authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
|
|
111
|
+
authUrl.searchParams.set("response_type", "code");
|
|
112
|
+
authUrl.searchParams.set("scope", "openid profile");
|
|
113
|
+
authUrl.searchParams.set("state", state);
|
|
114
|
+
authUrl.searchParams.set("code_challenge", challenge);
|
|
115
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
116
|
+
if (cfg.zk !== false)
|
|
117
|
+
authUrl.searchParams.set("zk_pub", zkPubParam);
|
|
118
|
+
location.assign(authUrl.toString());
|
|
119
|
+
}
|
|
120
|
+
export async function handleCallback() {
|
|
121
|
+
if (!location.search.includes("code="))
|
|
122
|
+
return null;
|
|
123
|
+
const params = new URLSearchParams(location.search);
|
|
124
|
+
const code = params.get("code");
|
|
125
|
+
if (!code)
|
|
126
|
+
return null;
|
|
127
|
+
const tokenUrl = new URL("/token", cfg.issuer);
|
|
128
|
+
const verifier = sessionStorage.getItem("pkce_verifier") || "";
|
|
129
|
+
const response = await fetch(tokenUrl.toString(), {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
132
|
+
body: new URLSearchParams({
|
|
133
|
+
grant_type: "authorization_code",
|
|
134
|
+
code,
|
|
135
|
+
client_id: cfg.clientId,
|
|
136
|
+
redirect_uri: cfg.redirectUri,
|
|
137
|
+
code_verifier: verifier,
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new Error("Token exchange failed");
|
|
142
|
+
}
|
|
143
|
+
const tokenResponse = await response.json();
|
|
144
|
+
const fragmentParams = parseFragmentParams(location.hash || "");
|
|
145
|
+
const drkJwe = fragmentParams.drk_jwe;
|
|
146
|
+
if (!drkJwe || typeof drkJwe !== "string")
|
|
147
|
+
throw new Error("Missing DRK JWE from URL fragment");
|
|
148
|
+
if (tokenResponse.zk_drk_hash) {
|
|
149
|
+
const hash = bytesToBase64Url(await sha256(new TextEncoder().encode(drkJwe)));
|
|
150
|
+
if (tokenResponse.zk_drk_hash !== hash)
|
|
151
|
+
throw new Error("DRK hash mismatch");
|
|
152
|
+
}
|
|
153
|
+
const privateJwkString = sessionStorage.getItem("zk_eph_priv_jwk");
|
|
154
|
+
if (!privateJwkString)
|
|
155
|
+
return null;
|
|
156
|
+
sessionStorage.removeItem("zk_eph_priv_jwk");
|
|
157
|
+
const privateKey = await crypto.subtle.importKey("jwk", JSON.parse(privateJwkString), { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits", "deriveKey"]);
|
|
158
|
+
const { plaintext } = await compactDecrypt(drkJwe, privateKey);
|
|
159
|
+
const drk = new Uint8Array(plaintext);
|
|
160
|
+
const idToken = tokenResponse.id_token;
|
|
161
|
+
const refreshToken = tokenResponse.refresh_token;
|
|
162
|
+
try {
|
|
163
|
+
history.replaceState(null, "", location.origin + location.pathname);
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
sessionStorage.setItem("id_token", idToken);
|
|
167
|
+
const obfuscatedDrk = obfuscateKey(drk);
|
|
168
|
+
localStorage.setItem("drk_protected", bytesToBase64Url(obfuscatedDrk));
|
|
169
|
+
if (refreshToken)
|
|
170
|
+
localStorage.setItem("refresh_token", refreshToken);
|
|
171
|
+
return { idToken, drk, refreshToken };
|
|
172
|
+
}
|
|
173
|
+
export function getStoredSession() {
|
|
174
|
+
const idToken = sessionStorage.getItem("id_token");
|
|
175
|
+
const obfuscatedDrkBase64 = localStorage.getItem("drk_protected");
|
|
176
|
+
if (!idToken || !obfuscatedDrkBase64)
|
|
177
|
+
return null;
|
|
178
|
+
if (!isTokenValid(idToken))
|
|
179
|
+
return null;
|
|
180
|
+
try {
|
|
181
|
+
const obfuscatedDrk = base64UrlToBytes(obfuscatedDrkBase64);
|
|
182
|
+
const drk = deobfuscateKey(obfuscatedDrk);
|
|
183
|
+
return { idToken, drk };
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
localStorage.removeItem("drk_protected");
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export async function refreshSession() {
|
|
191
|
+
const refreshToken = localStorage.getItem("refresh_token");
|
|
192
|
+
if (!refreshToken)
|
|
193
|
+
return null;
|
|
194
|
+
const tokenUrl = new URL("/token", cfg.issuer);
|
|
195
|
+
const response = await fetch(tokenUrl.toString(), {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
198
|
+
body: new URLSearchParams({
|
|
199
|
+
grant_type: "refresh_token",
|
|
200
|
+
refresh_token: refreshToken,
|
|
201
|
+
client_id: cfg.clientId,
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
localStorage.removeItem("refresh_token");
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const tokenResponse = await response.json();
|
|
209
|
+
const idToken = tokenResponse.id_token;
|
|
210
|
+
const newRefreshToken = tokenResponse.refresh_token;
|
|
211
|
+
sessionStorage.setItem("id_token", idToken);
|
|
212
|
+
if (newRefreshToken)
|
|
213
|
+
localStorage.setItem("refresh_token", newRefreshToken);
|
|
214
|
+
const obfuscatedDrkBase64 = localStorage.getItem("drk_protected");
|
|
215
|
+
if (!obfuscatedDrkBase64)
|
|
216
|
+
return null;
|
|
217
|
+
const obfuscatedDrk = base64UrlToBytes(obfuscatedDrkBase64);
|
|
218
|
+
const drk = deobfuscateKey(obfuscatedDrk);
|
|
219
|
+
return { idToken, drk, refreshToken: newRefreshToken || refreshToken };
|
|
220
|
+
}
|
|
221
|
+
export function logout() {
|
|
222
|
+
sessionStorage.removeItem("id_token");
|
|
223
|
+
localStorage.removeItem("drk_protected");
|
|
224
|
+
sessionStorage.removeItem("zk_eph_priv_jwk");
|
|
225
|
+
sessionStorage.removeItem("pkce_verifier");
|
|
226
|
+
localStorage.removeItem("refresh_token");
|
|
227
|
+
}
|
|
228
|
+
export function getCurrentUser() {
|
|
229
|
+
const idToken = sessionStorage.getItem("id_token");
|
|
230
|
+
if (!idToken)
|
|
231
|
+
return null;
|
|
232
|
+
return parseJwt(idToken);
|
|
233
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@darkauth/client",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepack": "npm run build",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"prepare": "tsc",
|
|
18
|
+
"format": "biome format --write .",
|
|
19
|
+
"lint": "biome lint .",
|
|
20
|
+
"check": "biome check .",
|
|
21
|
+
"check:fix": "biome check --write .",
|
|
22
|
+
"tidy": "biome check --write . && biome lint --write ."
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"jose": "^6.1.3"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "^5.9.3",
|
|
29
|
+
"@biomejs/biome": "^2.3.11"
|
|
30
|
+
}
|
|
31
|
+
}
|