PassGram is a standalone PHP password manager with no external dependencies (no Composer, no SQL database). All data lives in AES-256-GCM encrypted JSON files on disk. The UI is server-side rendered PHP with an OS/2 Warp 3.0 visual theme.
Every HTTP request hits index.php, which:
autoload.php, a minimal PSR-4 implementation).Session::start()).config/config.php and config/security.php.config/security.php and constructs the Encryption instance.Database, Logger, Validator, and Auth.$db if the logged-in user has PGP encryption mode active.login.php.?page= to the appropriate controller.Generated once at install. Encrypts all shared JSON databases (users.json.enc, groups.json.enc, invites.json.enc). Stored in config/security.php. Losing it makes all data unrecoverable.
Passwords are hashed with bcrypt (cost 12) for login verification. They are also used to derive a key via PBKDF2-SHA256 (100,000 iterations) for PGP private-key protection.
Each credential is its own file: data/credentials/{userId}/{prefix}_{title}.json.enc. The file is encrypted with the MAK using AES-256-GCM with a per-operation random 12-byte IV and 16-byte authentication tag. Within that file, sensitive fields (password, custom password fields) are individually re-encrypted with the same key.
The user generates an RSA/DSA/EC key pair. Their private key is stored as data/pgp/{userId}_private.key.enc (AES-256-GCM, passphrase-derived key). When PGP mode is active and the user provides their passphrase at login, $db->setPGPContext() is called and credentials are stored as {prefix}_{title}_gpg.pgp.enc using hybrid RSA+AES-256-GCM: a random session key is encrypted with the user's RSA public key and prepended to the AES ciphertext.
gnupg_enabled = true)A separate multi-recipient encryption path implemented in GnuPGEncryption + GroupShare. A single ciphertext is encrypted simultaneously to all group members' GnuPG public keys (one blob, N recipients) and stored in data/group_shares/. Each member decrypts independently with their own private key and passphrase — no shared secret or master key is involved. GroupShare::reEncrypt() re-encrypts for the updated recipient list when group membership changes. This path is implemented but not yet wired to the credential sharing UI; the active sharing path is still the AES/ACL system (Layer 3a). Requires gnupg_enabled = true in config and a GnuPG keyring on the server.
Export files (.passgram) use PBKDF2-SHA256 (100,000 iterations) to derive a key from a user-supplied passphrase, then AES-256-GCM-encrypt the credential payload. The passphrase never reaches the server during import — decryption runs in the browser before posting plaintext for re-encryption.
No SQL. All data is encrypted JSON on disk:
| Path | Contents |
|---|---|
data/users.json.enc |
All user accounts |
data/groups.json.enc |
Group definitions and membership |
data/invites.json.enc |
Invite codes |
data/credentials/index.csv |
Unencrypted fast-lookup index (prefix, filename, user_id, groups, encryption_mode) |
data/credentials/{userId}/{prefix}_{title}.json.enc |
One file per credential (AES mode) |
data/credentials/{userId}/{prefix}_{title}_gpg.pgp.enc |
One file per credential (PGP mode) |
data/pgpkeys/{userId}.json.enc |
User's PGP key store (AES mode) |
data/pgpkeys/public_catalog.json.enc |
Public PGP key directory |
data/pgp/{userId}_public.key |
PEM public key |
data/pgp/{userId}_private.key.enc |
AES-encrypted PEM private key |
data/avatars/{userId}.{ext} |
User avatar images (jpg/png/gif/webp) |
data/group_shares/<uuid>.json.enc |
GnuPG multi-recipient group share packets |
data/shares/ |
PGP-encrypted per-user credential share packets |
data/notes/ |
Encrypted notes |
data/logs/ |
Activity logs |
The credentials/index.csv exists so group-share lookups can scan one file instead of opening every user's encrypted credential directory.
admin or user, default user). The registrant receives that role automatically.user and admin. The first registered account is automatically admin.user_id, username, user_role, logged_in_at, and ip_address.Users can view and edit their profile at ?page=profile. The ProfileController provides four actions:
show — displays current username, email, bio, and avatar.update — POST; validates and applies username/email/bio changes; updates the session username on rename.upload-avatar — POST; validates image type via getimagesize(), enforces 2 MB limit, stores in data/avatars/{userId}.{ext}.avatar — GET; serves the user's avatar with correct MIME type and cache headers, or an SVG placeholder if no avatar exists.The username in the navigation bar is a link to the profile page displaying a small circular avatar thumbnail.
Credentials support types: password, note, card, identity. Each can have title, username, password, URL, notes, custom fields, tags, and folder. The built-in password generator uses random_int(). Passwords are copied from the list view via an AJAX get-password action that returns only the decrypted password for the targeted item.
The create form uses an explicit Visibility radio section: Personal (private) is the default; selecting Share with group(s) reveals the group multi-select and permission level fields via inline JavaScript.
Users belong to groups. Group owners can add/remove members, transfer ownership, or delete the group. Groups can be marked require_pgp — members must hold an RSA/EC key pair. The "Add Member" autocomplete filters to eligible users when this flag is set.
Group sharing (AES/ACL path — active): the credential owner calls shareWithGroup(), which writes a shared_with_groups entry into the credential's own encrypted file and updates index.csv. Any group member then reads that file directly from the owner's directory. Permissions are read or write.
Group sharing (GnuPG path — implemented, not yet wired): GroupShare::create() encrypts a credential to all group member GnuPG fingerprints simultaneously, storing one ciphertext in data/group_shares/. Requires all members to have imported GnuPG public keys and gnupg_enabled = true in config.
PGP-encrypted per-user sharing: the sender encrypts the credential with the recipient's PGP public key and stores the ciphertext in data/shares/. The recipient decrypts it with their private key.
Public shares: a time-limited unauthenticated link that exposes a single credential without requiring login.
Users can import public keys (RSA, DSA, EC) into their personal key store. Keys can be shared with groups or listed in the public, unauthenticated directory at ?page=keys.
.passgram export: AES-256-GCM with PBKDF2-derived passphrase key. Strips all internal IDs and sharing metadata; only portable fields travel.gpg-agent on localhost:7655, the browser fetches the plaintext from the server and encrypts it via the bridge. PassGram never sees the private key.Admins can view all users, suspend accounts, and generate 24-hour password reset tokens. An AuditLog model records all credential and user changes, encrypted like everything else.
| Control | Implementation |
|---|---|
| CSRF | CSRF class; token checked on every state-changing POST |
| XSS | Sanitizer helper; output escaped with htmlspecialchars() |
| Rate limiting | Session-based, per username+IP |
| Session hardening | httponly, samesite=Strict, secure (configurable) |
| Password storage | bcrypt cost-12 (auth) + PBKDF2-SHA256 (key derivation) |
| Authenticated encryption | AES-256-GCM everywhere — ciphertext is verified before decryption |
php-gnupg extension for GnuPG group shares