UNPKG

10.3 kBPlain TextView Raw
1import localforage from 'localforage'
2
3import * as common from './common/index'
4import * as identifiers from './common/identifiers'
5import * as ipfs from './ipfs/index'
6import * as pathing from './path'
7import * as crypto from './crypto/index'
8import * as storage from './storage/index'
9import * as ucan from './ucan/internal'
10import * as ucanPermissions from './ucan/permissions'
11import { setup } from './setup/internal'
12import * as did from './did/index'
13
14import { USERNAME_STORAGE_KEY, Maybe } from './common/index'
15import { Permissions } from './ucan/permissions'
16import { loadFileSystem } from './filesystem'
17
18import FileSystem from './fs/index'
19
20
21// SCENARIO
22
23
24export enum Scenario {
25 NotAuthorised = "NOT_AUTHORISED",
26 AuthSucceeded = "AUTH_SUCCEEDED",
27 AuthCancelled = "AUTH_CANCELLED",
28 Continuation = "CONTINUATION"
29}
30
31
32
33// STATE
34
35
36export type State
37 = NotAuthorised
38 | AuthSucceeded
39 | AuthCancelled
40 | Continuation
41
42export type NotAuthorised = {
43 scenario: Scenario.NotAuthorised
44 permissions: Maybe<Permissions>
45
46 authenticated: false
47}
48
49export type AuthSucceeded = {
50 scenario: Scenario.AuthSucceeded
51 permissions: Maybe<Permissions>
52
53 authenticated: true
54 newUser: boolean
55 throughLobby: true
56 username: string
57
58 fs?: FileSystem
59}
60
61export type AuthCancelled = {
62 scenario: Scenario.AuthCancelled
63 permissions: Maybe<Permissions>
64
65 authenticated: false
66 cancellationReason: string
67 throughLobby: true
68}
69
70export type Continuation = {
71 scenario: Scenario.Continuation
72 permissions: Maybe<Permissions>
73
74 authenticated: true
75 newUser: false
76 throughLobby: false
77 username: string
78
79 fs?: FileSystem
80}
81
82
83
84// ERRORS
85
86
87/**
88 * Initialisation error
89 */
90export enum InitialisationError {
91 InsecureContext = "INSECURE_CONTEXT",
92 UnsupportedBrowser = "UNSUPPORTED_BROWSER"
93}
94
95
96
97// INTIALISE
98
99
100/**
101 * Check if we're authenticated, process any lobby query-parameters present in the URL,
102 * and initiate the user's file system if authenticated (can be disabled).
103 *
104 * See `loadFileSystem` if you want to load the user's file system yourself.
105 * NOTE: Only works on the main/ui thread, as it uses `window.location`.
106 */
107export async function initialise(
108 options: {
109 permissions?: Permissions
110
111 // Options
112 autoRemoveUrlParams?: boolean
113 loadFileSystem?: boolean
114 rootKey?: string
115 }
116): Promise<State> {
117 options = options || {}
118
119 const permissions = options.permissions || null
120 const { autoRemoveUrlParams = true, rootKey } = options
121
122 const maybeLoadFs = async (username: string): Promise<undefined | FileSystem> => {
123 return options.loadFileSystem === false
124 ? undefined
125 : await loadFileSystem(permissions, username, rootKey)
126 }
127
128 // Check if browser is supported
129 if (globalThis.isSecureContext === false) throw InitialisationError.InsecureContext
130 if (await isSupported() === false) throw InitialisationError.UnsupportedBrowser
131
132 // URL things
133 const url = new URL(window.location.href)
134 const authorised = url.searchParams.get("authorised")
135 const cancellation = url.searchParams.get("cancelled")
136
137 // Determine scenario
138 if (authorised) {
139 const newUser = url.searchParams.get("newUser") === "t"
140 const username = url.searchParams.get("username") || ""
141
142 await importClassifiedInfo(
143 authorised === "via-postmessage"
144 ? await getClassifiedViaPostMessage()
145 : await ipfs.cat(authorised) // in any other case we expect it to be a CID
146 )
147
148 await storage.setItem(USERNAME_STORAGE_KEY, username)
149
150 if (autoRemoveUrlParams) {
151 url.searchParams.delete("authorised")
152 url.searchParams.delete("newUser")
153 url.searchParams.delete("username")
154 history.replaceState(null, document.title, url.toString())
155 }
156
157 if (permissions && await validateSecrets(permissions) === false) {
158 console.warn("Unable to validate filesystem secrets")
159 return scenarioNotAuthorised(permissions)
160 }
161
162 if (permissions && ucan.validatePermissions(permissions, username) === false) {
163 console.warn("Unable to validate UCAN permissions")
164 return scenarioNotAuthorised(permissions)
165 }
166
167 return scenarioAuthSucceeded(
168 permissions,
169 newUser,
170 username,
171 await maybeLoadFs(username)
172 )
173
174 } else if (cancellation) {
175 const c = (() => {
176 switch (cancellation) {
177 case "DENIED": return "User denied authorisation"
178 default: return "Unknown reason"
179 }
180 })()
181
182 return scenarioAuthCancelled(permissions, c)
183
184 } else {
185 // trigger build for internal ucan dictionary
186 await ucan.store([])
187
188 }
189
190 const authedUsername = await common.authenticatedUsername()
191
192 if (authedUsername && permissions) {
193 const validSecrets = await validateSecrets(permissions)
194 const validUcans = ucan.validatePermissions(permissions, authedUsername)
195
196 if (validSecrets && validUcans) {
197 return scenarioContinuation(permissions, authedUsername, await maybeLoadFs(authedUsername))
198 } else {
199 return scenarioNotAuthorised(permissions)
200 }
201
202 } else if (authedUsername) {
203 return scenarioContinuation(permissions, authedUsername, await maybeLoadFs(authedUsername))
204
205 } else {
206 return scenarioNotAuthorised(permissions)
207
208 }
209}
210
211
212/**
213 * Alias for `initialise`.
214 */
215export { initialise as initialize }
216
217
218
219// SUPPORTED
220
221
222export async function isSupported(): Promise<boolean> {
223 return localforage.supports(localforage.INDEXEDDB)
224
225 // Firefox in private mode can't use indexedDB properly,
226 // so we test if we can actually make a database.
227 && await (() => new Promise(resolve => {
228 const db = indexedDB.open("testDatabase")
229 db.onsuccess = () => resolve(true)
230 db.onerror = () => resolve(false)
231 }))() as boolean
232}
233
234
235
236// EXPORT
237
238
239export * from './auth'
240export * from './filesystem'
241export * from './common/version'
242
243export const fs = FileSystem
244
245export * as apps from './apps/index'
246export * as dataRoot from './data-root'
247export * as did from './did/index'
248export * as errors from './errors'
249export * as lobby from './lobby/index'
250export * as path from './path'
251export * as setup from './setup'
252export * as ucan from './ucan/index'
253
254export * as dns from './dns/index'
255export * as ipfs from './ipfs/index'
256export * as keystore from './keystore'
257export * as machinery from './common/index'
258export * as crypto from './crypto/index'
259export * as cbor from 'cborg'
260
261
262
263// ㊙️ ⚛ SCENARIOS
264
265
266function scenarioAuthSucceeded(
267 permissions: Maybe<Permissions>,
268 newUser: boolean,
269 username: string,
270 fs: FileSystem | undefined
271): AuthSucceeded {
272 return {
273 scenario: Scenario.AuthSucceeded,
274 permissions,
275
276 authenticated: true,
277 throughLobby: true,
278 fs,
279 newUser,
280 username
281 }
282}
283
284function scenarioAuthCancelled(
285 permissions: Maybe<Permissions>,
286 cancellationReason: string
287): AuthCancelled {
288 return {
289 scenario: Scenario.AuthCancelled,
290 permissions,
291
292 authenticated: false,
293 throughLobby: true,
294 cancellationReason
295 }
296}
297
298function scenarioContinuation(
299 permissions: Maybe<Permissions>,
300 username: string,
301 fs: FileSystem | undefined
302): Continuation {
303 return {
304 scenario: Scenario.Continuation,
305 permissions,
306
307 authenticated: true,
308 newUser: false,
309 throughLobby: false,
310 fs,
311 username
312 }
313}
314
315function scenarioNotAuthorised(
316 permissions: Maybe<Permissions>
317): NotAuthorised {
318 return {
319 scenario: Scenario.NotAuthorised,
320 permissions,
321
322 authenticated: false
323 }
324}
325
326
327
328// ㊙️
329
330interface AuthLobbyClassifiedInfo {
331 sessionKey: string
332 secrets: string
333 iv: string
334}
335
336
337async function importClassifiedInfo(
338 classified : string
339): Promise<void> {
340 const info: AuthLobbyClassifiedInfo = JSON.parse(classified)
341
342 // Extract session key and its iv
343 const rawSessionKey = await crypto.keystore.decrypt(info.sessionKey)
344
345 // Decrypt secrets
346 const secretsStr = await crypto.aes.decryptGCM(info.secrets, rawSessionKey, info.iv)
347 const secrets = JSON.parse(secretsStr)
348
349 const fsSecrets: Record<string, { key: string; bareNameFilter: string }> = secrets.fs
350 const ucans = secrets.ucans
351
352 // Import read keys and bare name filters
353 await Promise.all(
354 Object.entries(fsSecrets).map(async ([posixPath, { bareNameFilter, key }]) => {
355 const path = pathing.fromPosix(posixPath)
356 const readKeyId = await identifiers.readKey({ path })
357 const bareNameFilterId = await identifiers.bareNameFilter({ path })
358
359 await crypto.keystore.importSymmKey(key, readKeyId)
360 await storage.setItem(bareNameFilterId, bareNameFilter)
361 })
362 )
363
364 // Add UCANs to the storage
365 await ucan.store(ucans)
366}
367
368async function getClassifiedViaPostMessage(): Promise<string> {
369 const iframe: HTMLIFrameElement = await new Promise(resolve => {
370 const iframe = document.createElement("iframe")
371 iframe.id = "webnative-secret-exchange"
372 iframe.style.width = "0"
373 iframe.style.height = "0"
374 iframe.style.border = "none"
375 iframe.style.display = "none"
376 document.body.appendChild(iframe)
377
378 iframe.onload = () => {
379 resolve(iframe)
380 }
381
382 iframe.src = `${setup.endpoints.lobby}/exchange.html`
383 })
384
385 try {
386
387 const answer: Promise<string> = new Promise((resolve, reject) => {
388 window.addEventListener("message", listen)
389
390 function listen(event: MessageEvent<string>) {
391 window.removeEventListener("message", listen)
392 if (event.data) {
393 resolve(event.data)
394 } else {
395 reject(new Error("Can't import UCANs & readKey(s): Missing data"))
396 }
397 }
398 })
399
400 if (iframe.contentWindow == null) throw new Error("Can't import UCANs & readKey(s): No access to its contentWindow")
401 const message = {
402 webnative: "exchange-secrets",
403 didExchange: await did.exchange()
404 }
405 iframe.contentWindow.postMessage(message, iframe.src)
406
407 return await answer
408
409 } finally {
410 document.body.removeChild(iframe)
411 }
412}
413
414async function validateSecrets(permissions: Permissions): Promise<boolean> {
415 return ucanPermissions.paths(permissions).reduce(
416 (acc, path) => acc.then(async bool => {
417 if (bool === false) return bool
418 if (pathing.isBranch(pathing.Branch.Public, path)) return bool
419
420 const keyName = await identifiers.readKey({ path })
421 return await crypto.keystore.keyExists(keyName)
422 }),
423 Promise.resolve(true)
424 )
425}
426
\No newline at end of file