accounts and permissions
Massimo Melina committed
Dec 16, 2021 at 17:01 UTC
c07c81a339f6e1650d6bd18901d4dd877f7777e8
6 files changed
+204
-21
accounts.yaml
new
+3
@@ -0,0 +1,3 @@
1
+accounts:
2
+ rejetto:
3
+ hashedPassword: p2:djAx2PvVT+ygZ5H3FB4U2hwzgQ9CQAqh5CNzyMEaADIJt2Y/e6Ee+LjKEzSnQoxeivjFiFe3
src/apis.ts
+4
-2
@@ -1,9 +1,10 @@
1
import Koa from 'koa'
2
-import { vfs, VfsNode } from './vfs'
2
+import { directPermOnNode, vfs, VfsNode } from './vfs'
3
import { globDir } from './misc'
4
import { Stats } from 'fs'
5
import { stat } from 'fs/promises'
6
import _ from 'lodash'
7
+import { getCurrentUser } from './perm'
8
9
type ApiHandler = (params?:any, ctx?:any) => any
10
type ApiHandlers = Record<string, ApiHandler>
@@ -27,8 +28,9 @@ export const frontEndApis: ApiHandlers = {
28
let node = await vfs.urlToNode(params.path || '/')
29
if (!node)
30
return
31
+ const who = await getCurrentUser() // cache value
32
const list = await Promise.all((node.children ||[]).map(node =>
31
- !node.hidden && nodeToFile(node) ))
33
+ !node.hidden && directPermOnNode(node,who) && nodeToFile(node) ))
34
_.remove(list, x => !x)
35
let path = node.source
36
if (path) {
src/crypt.ts
new
+14
@@ -0,0 +1,14 @@
1
+// simple wrapper
2
+// @ts-ignore
3
+import { pbkdf2, pbkdf2Verify } from "./pbkdf2"
4
+import assert from 'assert'
5
+
6
+export async function hashPassword(s: string) {
7
+ return 'p2:' + await pbkdf2(s)
8
+}
9
+
10
+export async function verifyPassword(hashed: string, given: string) {
11
+ const i = hashed.indexOf(':')
12
+ assert(i>0, 'bad hashed')
13
+ return await pbkdf2Verify(hashed.slice(i+1), given) // for the time being we totally ignore the "method" part
14
+}
\ No newline at end of file
src/pbkdf2.ts
new
+84
@@ -0,0 +1,84 @@
1
+// @ts-nocheck
2
+import { webcrypto as crypto } from "node:crypto";
3
+export { pbkdf2, pbkdf2Verify }
4
+
5
+// FROM https://gist.github.com/chrisveness/770ee96945ec12ac84f134bf538d89fb
6
+
7
+/**
8
+ * Returns PBKDF2 derived key from supplied password.
9
+ *
10
+ * Stored key can subsequently be used to verify that a password matches the original password used
11
+ * to derive the key, using pbkdf2Verify().
12
+ *
13
+ * @param {String} password - Password to be hashed using key derivation function.
14
+ * @param {Number} [iterations=1e6] - Number of iterations of HMAC function to apply.
15
+ * @returns {String} Derived key as base64 string.
16
+ *
17
+ * @example
18
+ * const key = await pbkdf2('pāşšŵōřđ'); // eg 'djAxBRKXWNWPyXgpKWHld8SWJA9CQFmLyMbNet7Rle5RLKJAkBCllLfM6tPFa7bAis0lSTiB'
19
+ */
20
+async function pbkdf2(password, iterations=1e6) {
21
+ const pwUtf8 = new TextEncoder().encode(password); // encode pw as UTF-8
22
+ const pwKey = await crypto.subtle.importKey('raw', pwUtf8, 'PBKDF2', false, ['deriveBits']); // create pw key
23
+
24
+ const saltUint8 = crypto.getRandomValues(new Uint8Array(16)); // get random salt
25
+
26
+ const params = { name: 'PBKDF2', hash: 'SHA-256', salt: saltUint8, iterations: iterations }; // pbkdf2 params
27
+ const keyBuffer = await crypto.subtle.deriveBits(params, pwKey, 256); // derive key
28
+
29
+ const keyArray = Array.from(new Uint8Array(keyBuffer)); // key as byte array
30
+
31
+ const saltArray = Array.from(new Uint8Array(saltUint8)); // salt as byte array
32
+
33
+ const iterHex = ('000000'+iterations.toString(16)).slice(-6); // iter’n count as hex
34
+ const iterArray = iterHex.match(/.{2}/g).map(byte => parseInt(byte, 16)); // iter’ns as byte array
35
+
36
+ const compositeArray = [].concat(saltArray, iterArray, keyArray); // combined array
37
+ const compositeStr = compositeArray.map(byte => String.fromCharCode(byte)).join(''); // combined as string
38
+ const compositeBase64 = btoa('v01'+compositeStr); // encode as base64
39
+
40
+ return compositeBase64; // return composite key
41
+}
42
+
43
+
44
+/**
45
+ * Verifies whether the supplied password matches the password previously used to generate the key.
46
+ *
47
+ * @param {String} key - Key previously generated with pbkdf2().
48
+ * @param {String} password - Password to be matched against previously derived key.
49
+ * @returns {boolean} Whether password matches key.
50
+ *
51
+ * @example
52
+ * const match = await pbkdf2Verify(key, 'pāşšŵōřđ'); // true
53
+ */
54
+async function pbkdf2Verify(key, password) {
55
+ let compositeStr = null; // composite key is salt, iteration count, and derived key
56
+ try { compositeStr = atob(key); } catch (e) { throw new Error ('Invalid key'); } // decode from base64
57
+
58
+ const version = compositeStr.slice(0, 3); // 3 bytes
59
+ const saltStr = compositeStr.slice(3, 19); // 16 bytes (128 bits)
60
+ const iterStr = compositeStr.slice(19, 22); // 3 bytes
61
+ const keyStr = compositeStr.slice(22, 54); // 32 bytes (256 bits)
62
+
63
+ if (version !== 'v01') throw new Error('Invalid key');
64
+
65
+ // -- recover salt & iterations from stored (composite) key
66
+
67
+ const saltUint8 = new Uint8Array(saltStr.match(/./g).map(ch => ch.charCodeAt(0))); // salt as Uint8Array
68
+ // note: cannot use TextEncoder().encode(saltStr) as it generates UTF-8
69
+
70
+ const iterHex = iterStr.match(/./g).map(ch => ch.charCodeAt(0).toString(16)).join(''); // iter’n count as hex
71
+ const iterations = parseInt(iterHex, 16); // iter’ns
72
+
73
+ // -- generate new key from stored salt & iterations and supplied password
74
+
75
+ const pwUtf8 = new TextEncoder().encode(password); // encode pw as UTF-8
76
+ const pwKey = await crypto.subtle.importKey('raw', pwUtf8, 'PBKDF2', false, ['deriveBits']); // create pw key
77
+
78
+ const params = { name: 'PBKDF2', hash: 'SHA-256', salt: saltUint8, iterations: iterations }; // pbkdf params
79
+ const keyBuffer = await crypto.subtle.deriveBits(params, pwKey, 256); // derive key
80
+ const keyArray = Array.from(new Uint8Array(keyBuffer)); // key as byte array
81
+ const keyStrNew = keyArray.map(byte => String.fromCharCode(byte)).join(''); // key as string
82
+
83
+ return keyStrNew === keyStr; // test if newly generated key matches stored key
84
+}
\ No newline at end of file
src/perm.ts
new
+61
@@ -0,0 +1,61 @@
1
+import { watch } from 'fs'
2
+import fs from 'fs/promises'
3
+import _ from 'lodash'
4
+import yaml from 'yaml'
5
+import { hashPassword } from './crypt'
6
+
7
+const PATH = 'accounts.yaml'
8
+
9
+interface UserDetails {
10
+ user: string, // we'll have user in it, so we don't need to pass it separately
11
+ password?: string
12
+ hashedPassword: string
13
+}
14
+interface Accounts { [username:string]: UserDetails }
15
+
16
+let accounts: Accounts = {}
17
+
18
+export async function getCurrentUser() {
19
+ return 'max'
20
+}
21
+
22
+let doing = false
23
+load().then()
24
+try { watch(PATH, load) } // find a better way to handle missing file
25
+catch(e){}
26
+async function load() {
27
+ if (doing) return
28
+ doing = true
29
+ try {
30
+ console.debug('loading', PATH)
31
+ let file
32
+ try {
33
+ file = await fs.readFile(PATH, 'utf8')
34
+ }
35
+ catch(e){
36
+ console.warn('cannot read', PATH)
37
+ return
38
+ }
39
+ const res = yaml.parse(file)
40
+ // we should validate content here
41
+ if (!res?.accounts)
42
+ return accounts = {}
43
+ accounts = res.accounts
44
+ let changed = false
45
+ await Promise.all(_.map(accounts, async (rec,k) => {
46
+ Object.defineProperty(rec, 'user', {
47
+ enumerable: false, // sneak it in
48
+ value: k,
49
+ })
50
+ if (rec.password) {
51
+ rec.hashedPassword = await hashPassword(rec.password)
52
+ delete rec.password
53
+ changed = true
54
+ console.debug('hashing password for', k)
55
+ }
56
+ }))
57
+ if (changed)
58
+ await fs.writeFile(PATH, yaml.stringify(res))
59
+ }
60
+ finally { doing = false }
61
+}
src/vfs.ts
+38
-19
@@ -5,6 +5,7 @@ import { FSWatcher, watch } from 'fs'
5
import { basename } from 'path'
6
import { isMatch } from 'micromatch'
7
import { complySlashes, prefix } from './misc'
8
+import { getCurrentUser } from './perm'
9
10
enum VfsNodeType {
11
root,
@@ -19,8 +20,11 @@ export interface VfsNode {
20
remove?: string | string[],
21
hidden?: boolean,
22
rename?: Record<string,string>,
23
+ perm?: Record<string, SinglePerm>
24
}
25
26
+type SinglePerm = 'r' | 'w'
27
+
28
const EMPTY = { type: VfsNodeType.root }
29
30
export class Vfs {
@@ -69,32 +73,47 @@ export class Vfs {
73
}
74
75
async urlToNode(url: string) {
76
+ const who = await getCurrentUser()
77
let run = this.root
78
const rest = url.split('/').filter(Boolean)
79
+ if (forbidden()) return
80
while (rest.length) {
75
- let piece = rest.shift()
76
- const { rename } = run
77
- if (rename)
78
- for (const k in rename)
79
- if (rename[k] === piece) {
80
- piece = k
81
- break
82
- }
83
- // @ts-ignore
84
- const find = run?.children?.find(x => x.name === piece)
85
- if (!find) {
86
- if (!run.source)
87
- return null
88
- const relativeSource = piece + prefix('/', rest.join('/'))
89
- const baseSource = complySlashes(run.source+ '/')
90
- const source = baseSource + relativeSource
91
- const removed = isMatch(source, [run.remove].flat().map(x => baseSource + x))
92
- return removed || !await fs.stat(source) ? null : { source }
81
+ let piece = rest.shift() as string
82
+ const child = findChildByName(piece, run)
83
+ if (child) {
84
+ run = child
85
+ if (forbidden()) return
86
+ continue
87
}
94
- run = find
88
+ if (!run.source)
89
+ return null
90
+ const relativeSource = piece + prefix('/', rest.join('/'))
91
+ const baseSource = complySlashes(run.source+ '/') //** serve comply qui?
92
+ const source = baseSource + relativeSource
93
+ const removed = isMatch(source, [run.remove].flat().map(x => baseSource + x))
94
+ return removed || !await fs.stat(source) ? null : { source }
95
}
96
return run
97
+
98
+ function forbidden() {
99
+ const { perm } = run
100
+ return perm && (!perm[who] || perm['*'])
101
+ }
102
+
103
}
104
+
105
}
106
107
export const vfs = new Vfs(argv._[0])
108
+
109
+function findChildByName(name:string, node:VfsNode) {
110
+ const { rename } = node
111
+ if (rename) // @ts-ignore
112
+ name = Object.entries(rename).find(([,v]) => name === v)[0] || name
113
+ return node?.children?.find(x => x.name === name)
114
+}
115
+
116
+export function directPermOnNode(node:VfsNode, username:string) {
117
+ const { perm } = node
118
+ return !perm ? 'r' : (perm[username] || perm['*'])
119
+}
\ No newline at end of file