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