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.
@@ -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
+ }