1 | import localforage from 'localforage'
|
2 |
|
3 | import * as common from './common/index'
|
4 | import * as identifiers from './common/identifiers'
|
5 | import * as ipfs from './ipfs/index'
|
6 | import * as pathing from './path'
|
7 | import * as crypto from './crypto/index'
|
8 | import * as storage from './storage/index'
|
9 | import * as ucan from './ucan/internal'
|
10 | import * as ucanPermissions from './ucan/permissions'
|
11 | import { setup } from './setup/internal'
|
12 | import * as did from './did/index'
|
13 |
|
14 | import { USERNAME_STORAGE_KEY, Maybe } from './common/index'
|
15 | import { Permissions } from './ucan/permissions'
|
16 | import { loadFileSystem } from './filesystem'
|
17 |
|
18 | import FileSystem from './fs/index'
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 | export enum Scenario {
|
25 | NotAuthorised = "NOT_AUTHORISED",
|
26 | AuthSucceeded = "AUTH_SUCCEEDED",
|
27 | AuthCancelled = "AUTH_CANCELLED",
|
28 | Continuation = "CONTINUATION"
|
29 | }
|
30 |
|
31 |
|
32 |
|
33 |
|
34 |
|
35 |
|
36 | export type State
|
37 | = NotAuthorised
|
38 | | AuthSucceeded
|
39 | | AuthCancelled
|
40 | | Continuation
|
41 |
|
42 | export type NotAuthorised = {
|
43 | scenario: Scenario.NotAuthorised
|
44 | permissions: Maybe<Permissions>
|
45 |
|
46 | authenticated: false
|
47 | }
|
48 |
|
49 | export 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 |
|
61 | export type AuthCancelled = {
|
62 | scenario: Scenario.AuthCancelled
|
63 | permissions: Maybe<Permissions>
|
64 |
|
65 | authenticated: false
|
66 | cancellationReason: string
|
67 | throughLobby: true
|
68 | }
|
69 |
|
70 | export 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 |
|
85 |
|
86 |
|
87 |
|
88 |
|
89 |
|
90 | export enum InitialisationError {
|
91 | InsecureContext = "INSECURE_CONTEXT",
|
92 | UnsupportedBrowser = "UNSUPPORTED_BROWSER"
|
93 | }
|
94 |
|
95 |
|
96 |
|
97 |
|
98 |
|
99 |
|
100 |
|
101 |
|
102 |
|
103 |
|
104 |
|
105 |
|
106 |
|
107 | export async function initialise(
|
108 | options: {
|
109 | permissions?: Permissions
|
110 |
|
111 |
|
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 |
|
129 | if (globalThis.isSecureContext === false) throw InitialisationError.InsecureContext
|
130 | if (await isSupported() === false) throw InitialisationError.UnsupportedBrowser
|
131 |
|
132 |
|
133 | const url = new URL(window.location.href)
|
134 | const authorised = url.searchParams.get("authorised")
|
135 | const cancellation = url.searchParams.get("cancelled")
|
136 |
|
137 |
|
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)
|
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 | */
|
215 | export { initialise as initialize }
|
216 |
|
217 |
|
218 |
|
219 | // SUPPORTED
|
220 |
|
221 |
|
222 | export 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 |
|
239 | export * from './auth'
|
240 | export * from './filesystem'
|
241 | export * from './common/version'
|
242 |
|
243 | export const fs = FileSystem
|
244 |
|
245 | export * as apps from './apps/index'
|
246 | export * as dataRoot from './data-root'
|
247 | export * as did from './did/index'
|
248 | export * as errors from './errors'
|
249 | export * as lobby from './lobby/index'
|
250 | export * as path from './path'
|
251 | export * as setup from './setup'
|
252 | export * as ucan from './ucan/index'
|
253 |
|
254 | export * as dns from './dns/index'
|
255 | export * as ipfs from './ipfs/index'
|
256 | export * as keystore from './keystore'
|
257 | export * as machinery from './common/index'
|
258 | export * as crypto from './crypto/index'
|
259 | export * as cbor from 'cborg'
|
260 |
|
261 |
|
262 |
|
263 | // ㊙️ ⚛ SCENARIOS
|
264 |
|
265 |
|
266 | function 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 |
|
284 | function 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 |
|
298 | function 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 |
|
315 | function 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 |
|
330 | interface AuthLobbyClassifiedInfo {
|
331 | sessionKey: string
|
332 | secrets: string
|
333 | iv: string
|
334 | }
|
335 |
|
336 |
|
337 | async 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 |
|
368 | async 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 |
|
414 | async 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 |