meyi-vault-server 1.0.0
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/README.md +95 -0
- package/dist/index.mjs +37038 -0
- package/package.json +33 -0
- package/src/database/migrate.mjs +110 -0
- package/src/database/schema.mjs +89 -0
- package/src/index.mjs +139 -0
- package/src/routes/audit.mjs +75 -0
- package/src/routes/domains.mjs +80 -0
- package/src/routes/entries.mjs +170 -0
- package/src/routes/grants.mjs +141 -0
- package/src/routes/groups.mjs +127 -0
- package/src/routes/stats.mjs +50 -0
- package/src/routes/vaults.mjs +156 -0
- package/src/utils/audit.mjs +57 -0
- package/src/utils/auth.mjs +60 -0
- package/src/utils/crypto.mjs +97 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { eq, and, isNull, or, sql, gt } from 'drizzle-orm'
|
|
2
|
+
import { vaultVaults, vaultGrants, vaultGroups, vaultEntries } from '../database/schema.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if a user has access to a vault.
|
|
6
|
+
* @returns {Promise<{ access: boolean, isOwner: boolean, vault: any }>}
|
|
7
|
+
*/
|
|
8
|
+
export async function checkVaultAccess(db, vaultId, user, requireOwner = false) {
|
|
9
|
+
const [vault] = await db.select().from(vaultVaults).where(eq(vaultVaults.id, vaultId))
|
|
10
|
+
if (!vault) return { access: false, isOwner: false, vault: null }
|
|
11
|
+
|
|
12
|
+
const isOwner = vault.owner_id === user.id || user.role === 'admin'
|
|
13
|
+
if (isOwner) return { access: true, isOwner: true, vault }
|
|
14
|
+
|
|
15
|
+
if (requireOwner) return { access: false, isOwner: false, vault }
|
|
16
|
+
|
|
17
|
+
// Check for any active grant for this user that covers this vault or any children
|
|
18
|
+
// To handle child-level grants properly, we find all grants for this user
|
|
19
|
+
// that are either for this vault OR for groups/entries within this vault.
|
|
20
|
+
|
|
21
|
+
const activeGrants = await db.select().from(vaultGrants).where(
|
|
22
|
+
and(
|
|
23
|
+
eq(vaultGrants.grantee_id, user.id),
|
|
24
|
+
isNull(vaultGrants.revoked_at),
|
|
25
|
+
or(
|
|
26
|
+
isNull(vaultGrants.expires_at),
|
|
27
|
+
gt(vaultGrants.expires_at, sql`now()`)
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// Filter grants that belong to this vault
|
|
33
|
+
for (const grant of activeGrants) {
|
|
34
|
+
if (grant.scope === 'vault' && grant.scope_id === vaultId) return { access: true, isOwner: false, vault }
|
|
35
|
+
|
|
36
|
+
// Check if it's a child resource of this vault
|
|
37
|
+
const grantVaultId = await getVaultIdForScope(db, grant.scope, grant.scope_id)
|
|
38
|
+
if (grantVaultId === vaultId) return { access: true, isOwner: false, vault }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { access: false, isOwner: false, vault }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Finds the vaultId for a given scope and id.
|
|
46
|
+
*/
|
|
47
|
+
export async function getVaultIdForScope(db, scope, id) {
|
|
48
|
+
if (scope === 'vault') return id
|
|
49
|
+
if (scope === 'group') {
|
|
50
|
+
const [g] = await db.select().from(vaultGroups).where(eq(vaultGroups.id, id))
|
|
51
|
+
return g?.vault_id
|
|
52
|
+
}
|
|
53
|
+
if (scope === 'entry') {
|
|
54
|
+
const [e] = await db.select().from(vaultEntries).where(eq(vaultEntries.id, id))
|
|
55
|
+
if (!e) return null
|
|
56
|
+
const [g] = await db.select().from(vaultGroups).where(eq(vaultGroups.id, e.group_id))
|
|
57
|
+
return g?.vault_id
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-GCM encryption utilities.
|
|
3
|
+
*
|
|
4
|
+
* Design decisions:
|
|
5
|
+
* - AES-256-GCM provides authenticated encryption — gives both
|
|
6
|
+
* confidentiality AND integrity. A tampered ciphertext will throw
|
|
7
|
+
* on decrypt rather than silently return garbage.
|
|
8
|
+
* - 12-byte IV is the NIST-recommended length for GCM. Generates a
|
|
9
|
+
* fresh random IV per encryption — never reuse under the same key.
|
|
10
|
+
* - 16-byte (128-bit) auth tag — maximum GCM tag length.
|
|
11
|
+
* - All binary values stored as base64 strings for safe JSON/DB transport.
|
|
12
|
+
* - Key stored as a 64-char hex string (32 bytes).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate a new random AES-256 vault key.
|
|
19
|
+
* Returns a 64-character hex string.
|
|
20
|
+
*/
|
|
21
|
+
export function generateVaultKey() {
|
|
22
|
+
return randomBytes(32).toString('hex')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Encrypt a UTF-8 plaintext string.
|
|
27
|
+
* @param {string} plaintext
|
|
28
|
+
* @param {string} keyHex 64-char hex string (32 bytes)
|
|
29
|
+
* @returns {{ encrypted_data: string, iv: string, auth_tag: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function encrypt(plaintext, keyHex) {
|
|
32
|
+
const key = Buffer.from(keyHex, 'hex')
|
|
33
|
+
if (key.length !== 32) throw new Error('AES key must be 32 bytes (64 hex chars)')
|
|
34
|
+
|
|
35
|
+
const iv = randomBytes(12) // 96-bit IV — NIST recommended for GCM
|
|
36
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 })
|
|
37
|
+
|
|
38
|
+
const ciphertext = Buffer.concat([
|
|
39
|
+
cipher.update(plaintext, 'utf8'),
|
|
40
|
+
cipher.final(),
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
encrypted_data: ciphertext.toString('base64'),
|
|
45
|
+
iv: iv.toString('base64'),
|
|
46
|
+
auth_tag: cipher.getAuthTag().toString('base64'),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decrypt AES-256-GCM ciphertext.
|
|
52
|
+
* Throws ERR_CRYPTO_INVALID_AUTH_TAG if the data was tampered with.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} encryptedData base64 ciphertext
|
|
55
|
+
* @param {string} iv base64 12-byte IV
|
|
56
|
+
* @param {string} authTag base64 16-byte GCM auth tag
|
|
57
|
+
* @param {string} keyHex 64-char hex string
|
|
58
|
+
* @returns {string} decrypted UTF-8 string
|
|
59
|
+
*/
|
|
60
|
+
export function decrypt(encryptedData, iv, authTag, keyHex) {
|
|
61
|
+
const key = Buffer.from(keyHex, 'hex')
|
|
62
|
+
if (key.length !== 32) throw new Error('AES key must be 32 bytes')
|
|
63
|
+
|
|
64
|
+
const decipher = createDecipheriv(
|
|
65
|
+
'aes-256-gcm',
|
|
66
|
+
key,
|
|
67
|
+
Buffer.from(iv, 'base64'),
|
|
68
|
+
{ authTagLength: 16 }
|
|
69
|
+
)
|
|
70
|
+
// Must set auth tag before final() — throws if tampered
|
|
71
|
+
decipher.setAuthTag(Buffer.from(authTag, 'base64'))
|
|
72
|
+
|
|
73
|
+
return Buffer.concat([
|
|
74
|
+
decipher.update(Buffer.from(encryptedData, 'base64')),
|
|
75
|
+
decipher.final(), // ← throws if auth tag mismatch
|
|
76
|
+
]).toString('utf8')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Convert an expiresIn shorthand to a future Date.
|
|
81
|
+
* Returns null if no expiry requested.
|
|
82
|
+
*
|
|
83
|
+
* @param {'1h'|'8h'|'24h'|'7d'|'30d'|null} expiresIn
|
|
84
|
+
* @returns {Date|null}
|
|
85
|
+
*/
|
|
86
|
+
export function expiresInToDate(expiresIn) {
|
|
87
|
+
if (!expiresIn) return null
|
|
88
|
+
const seconds = { '1h': 3600, '8h': 28800, '24h': 86400, '7d': 604800, '30d': 2592000 }
|
|
89
|
+
|
|
90
|
+
if (seconds[expiresIn]) {
|
|
91
|
+
return new Date(Date.now() + seconds[expiresIn] * 1000)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Try parsing as ISO string for custom dates
|
|
95
|
+
const date = new Date(expiresIn)
|
|
96
|
+
return isNaN(date.getTime()) ? null : date
|
|
97
|
+
}
|