Skip to main content

Keepiq — Architecture & Data Model

1. Overview​

Keepiq is an encrypted secrets manager for Nextcloud — a password manager and key store for users and applications. It stores secrets (passwords, API keys, tokens, certificates) encrypted at rest using RSA-4096 public-key cryptography, with private keys protected by AES-256 encryption derived from a user's master password. A private Certificate Authority (root + intermediate) signs all user and application certificates.

Architecture Pattern​

┌─────────────────────────────────────────────────────────────┐
│ Keepiq Frontend (Vue 2 + Pinia) │
│ - Lock screen (master password entry) │
│ - Vault: secrets list with folder tree │
│ - Secret detail views (by type) │
│ - Sharing UI (user, link, request) │
│ - Key generator │
│ - Dashboard (KPI cards, vault health) │
│ - Admin settings (CA health, app management) │
│ - User settings (session timeout, notifications) │
└──────────────┬──────────────────────────────────────────────┘
│ REST API calls (Nextcloud session + X-Vault-Password)
┌──────────────▼──────────────────────────────────────────────┐
│ Keepiq PHP Backend │
│ - Controllers: Secrets, EncryptionSuites, Applications, │
│ Sharing, KeyGenerator, Settings │
│ - Services: EncryptionService, CertificateAuthorityService,│
│ SecretService, SharingService, MigrationService │
│ - Entities: Doctrine ORM with Nextcloud migrations │
│ - Encryption: OpenSSL (RSA-4096 + AES-256) │
└──────────────┬──────────────────────────────────────────────┘
│ Doctrine ORM
┌──────────────▼──────────────────────────────────────────────┐
│ PostgreSQL Database │
│ - Own tables (ISchemaWrapper migrations) │
│ - Encrypted fields: private keys (AES), secret values (RSA)│
│ - Unencrypted fields: names, URLs, metadata │
└─────────────────────────────────────────────────────────────┘

Keepiq owns all its database tables — it does NOT use OpenRegister. This is an explicit exception to the org-wide ADR-001, justified by the security requirements of field-level encryption control and the need to eliminate intermediary services from the data path. See ADR-001.

2. Standards Research​

Before defining our encryption architecture and data model, we evaluated multiple standards across cryptography, key management, and password security.

2.1 Standards Evaluated​

StandardTypeCoverageMaturityRelevance
X.509 / RFC 5280International (IETF)Certificate format, CA chains, certificate revocation, extensionsVery mature (since 1988)HIGH — defines certificate structure for our private CA
PKCS#1 / RFC 8017International (IETF)RSA encryption and signature schemes, OAEP paddingVery matureHIGH — RSA encryption of secrets
PKCS#8 / RFC 5958International (IETF)Private key storage format, encrypted private key wrappingVery matureHIGH — AES-encrypted private key storage
PKCS#10 / RFC 2986International (IETF)Certificate Signing Request (CSR) formatVery matureHIGH — application registration via CSR
NIST SP 800-57US GovernmentKey management lifecycle, key sizes, algorithm selectionCurrent (Rev. 5, 2020)HIGH — RSA-4096 and AES-256 recommendations
NIST SP 800-63BUS GovernmentAuthenticator assurance levels, memorized secret requirementsCurrent (Rev. 4 draft)HIGH — master password strength policy
OWASP Password StorageIndustry (OWASP)Password hashing, KDF selection (Argon2id, bcrypt, PBKDF2)Active, regularly updatedHIGH — Argon2id for link share KDF
OWASP Cryptographic StorageIndustry (OWASP)Encryption at rest, key management, algorithm selectionActiveHIGH — encryption architecture validation
zxcvbnIndustry (Dropbox)Password strength estimation, entropy-based scoring (0–4)Mature, widely adoptedHIGH — master password strength meter
Argon2 / RFC 9106International (IETF)Memory-hard KDF: Argon2d, Argon2i, Argon2idStandardized 2021HIGH — link share snapshot encryption
WebAuthn / FIDO2International (W3C)Passwordless authentication, hardware key supportMature, growing adoptionLOW — future: passwordless vault unlock
Schema.orgInternational (W3C)Structured data vocabularyVery matureLOW — no standard types for secrets/credentials

2.2 Design Principle: Security First, Standards-Based​

All cryptographic operations use established standards via OpenSSL. No custom cryptography. The master password never touches the database.

This means:

  • RSA encryption follows PKCS#1 v1.5 / OAEP padding via OpenSSL
  • Private keys are stored in PKCS#8 PEM format, AES-256 encrypted
  • Certificates follow X.509 v3 format, signed by the private CA intermediate
  • CSR processing follows PKCS#10 for application registration
  • Key derivation for link shares uses Argon2id (memory-hard, RFC 9106)
  • Master password strength is validated using zxcvbn entropy scoring aligned with NIST SP 800-63B

2.3 Key Findings​

  1. X.509 / RFC 5280 defines the certificate format Keepiq uses for its private CA. The root certificate (20-year lifetime) signs an intermediate certificate (3-year lifetime), which in turn signs user and application certificates. This two-tier CA structure follows PKI best practice — the root key is used rarely, limiting exposure.

  2. NIST SP 800-57 recommends RSA-4096 for protection beyond 2031 (>128-bit security strength). AES-256 provides 256-bit security strength. Both exceed NIST's minimum recommendations for sensitive data protection. Key sizes in Keepiq are only allowed to increase, never decrease.

  3. NIST SP 800-63B defines memorized secret (password) requirements: minimum 8 characters (we require 12), no composition rules (we use entropy-based scoring instead), and blocklist checking. Our zxcvbn-based approach aligns with NIST's shift from complexity rules to entropy measurement.

  4. OWASP Password Storage Cheat Sheet recommends Argon2id as the first choice for password hashing / key derivation. We use Argon2id for link share snapshot encryption (deriving an AES key from the link password). For master password → AES key derivation, we use a similar KDF approach.

  5. PKCS#10 (CSR) enables applications to register with Keepiq by submitting a Certificate Signing Request containing their public key. Keepiq signs the public key with the CA intermediate and never stores the application's private key — the application manages its own key externally. This is standard PKI practice for distributed systems.

  6. RSA chunking constraint (PKCS#1): RSA-4096 can encrypt at most ~500 bytes per operation (key size in bytes − padding overhead). Secrets larger than this limit must be chunked. This is a known limitation documented in ADR-003.

  7. zxcvbn scores passwords on a 0–4 scale: 0 = too guessable, 1 = very guessable, 2 = somewhat guessable, 3 = safely unguessable (resists online attacks, ~10^8 guesses), 4 = very unguessable (resists offline attacks, ~10^10 guesses). Keepiq requires score ≥ 3 by default, configurable up to 4 by admins.

  8. No applicable Schema.org types — Schema.org has no standard vocabulary for secrets, credentials, or vault entries. Keepiq entities do not carry schema.org type annotations, unlike other Conduction apps that model semantic data.

3. Data Model Decisions​

3.1 Standards Hierarchy​

LayerStandardPurpose
CryptographyRSA (PKCS#1), AES-256, OpenSSLEncryption of secrets and private keys
Certificate formatX.509 v3 (RFC 5280), PKCS#8, PKCS#10CA certificates, key storage, CSR processing
Key derivationArgon2id (RFC 9106)Link share password → AES key derivation
Password policyNIST SP 800-63B + zxcvbnMaster password strength enforcement
Key managementNIST SP 800-57Key size selection, lifecycle management
Nextcloud nativeOCP interfacesUsers, sessions, notifications, search, files

3.2 Entity Definitions​

EncryptionSuite​

The cryptographic identity of a user or application. Holds a public certificate (for encrypting secrets) and an AES-encrypted private key (for decrypting them). See encryption-suites spec for full requirements.

AspectDecisionRationale
StandardX.509 v3 certificate + PKCS#8 private keyIndustry-standard PKI formats
OwnershipPolymorphic: owner_type (user | application) + owner_idSingle table, single query path — see ADR-002
Key sizeRSA-4096 minimum (only allowed to increase)NIST SP 800-57 recommendation for long-term protection
Private key protectionAES-256 encrypted with master password derived keyMaster password never stored — see ADR-003
Certificate DNDefault DN fields + owner-specific CNSee certificate DN section below

Core properties:

PropertyTypeEncryptedStandardNotes
idUUIDNo—Primary key
owner_typeenum: user, applicationNoADR-002Polymorphic ownership
owner_idstringNo—Nextcloud user ID or Application ID
certificatetext (PEM)NoX.509 v3Public certificate signed by CA intermediate
private_keytext (PEM)Yes (AES-256)PKCS#8Encrypted with master password derived key
statusenum: active, revoked, compromisedNoX.509 CRL conceptLifecycle state
revoked_atdatetimeNo—Audit: when revoked
revoked_reasonstringNoX.509 CRL reasonAudit: why revoked
revoked_bystringNo—Audit: who revoked
reinstated_atdatetimeNo—Audit: when reinstated
reinstated_bystringNo—Audit: who reinstated
created_atdatetimeNo——

Certificate Distinguished Name (DN)​

All certificates issued by the Keepiq CA share a common set of DN fields, with the commonName varying by certificate type:

Default DN fields (defined as DEFAULT_DN in CertificateAuthorityService):

FieldValue
countryNameNL
stateOrProvinceNameNoord-Holland
localityNameAmsterdam
organizationNameConduction
organizationalUnitNameKeepiq

Common Name by certificate type:

CertificateCommon NameExample
Root CAKeepiq Root CAKeepiq Root CA
Intermediate CAKeepiq Intermediate CAKeepiq Intermediate CA
User certificateFederated cloud ID (user@instance), falls back to user IDadmin@nextcloud.local
Application certificateApplication IDa1b2c3d4-...

The cloud ID is resolved via IUserManager::get($ownerId)->getCloudId() in EncryptionSuiteService::resolveCommonName(). This ensures user certificates contain the full federated identity. When a certificate is re-signed during CA renewal, the original CN is preserved by extracting it from the existing certificate via openssl_x509_parse().

CACertificate​

The private Certificate Authority that signs all user and application certificates.

AspectDecisionRationale
StructureTwo-tier: root (20yr) → intermediate (3yr)PKI best practice — root key used rarely
StandardX.509 v3 certificatesIndustry standard
RenewalIntermediate auto-renews; root requires manual admin actionMinimize disruption; root rotation is rare

Core properties:

PropertyTypeEncryptedStandardNotes
idUUIDNo—Primary key
typeenum: root, intermediateNoPKI conventionCertificate tier
certificatetext (PEM)NoX.509 v3Public certificate
private_keytext (PEM)Yes (AES-256)PKCS#8Only present for intermediate
is_activeboolNo—Only one intermediate active for signing
expires_atdatetimeNoX.509 validityFor efficient expiry queries
revoked_atdatetimeNoX.509 CRLSet on forced revocation
successor_idFKNo—Points to replacement certificate
created_atdatetimeNo——

Secret​

The core data entity — holds sensitive information encrypted with the owner's public certificate.

AspectDecisionRationale
EncryptionRSA-4096 (PKCS#1) per owner's public certificateAsymmetric: enables write-without-read
Searchable fieldsname and url stored unencryptedEnables search and Nextcloud unified search without master password
Folder organizationTree via parent_id on Folder entityUser-managed hierarchy, no stored path strings

Core properties:

PropertyTypeEncryptedStandardNotes
idUUIDNo—Primary key
namestringNo—Human-readable label — safe for lists/search
urlstringNo—Target URL — enables search without decryption
type_idFKNo—SecretType reference; defaults to login
folder_idFKNo—Folder reference; null = root level
keytextYes (RSA)PKCS#1The actual secret value
loginstringYes (RSA)—Username, client ID, or equivalent
additional_fieldstextYes (RSA)—JSON blob of extra key-value pairs
encryption_suite_idFKNo—Which suite encrypted this secret
owner_typeenum: user, applicationNoADR-002Polymorphic ownership
owner_idstringNo—Nextcloud user ID or Application ID
possibly_compromised_atdatetimeNo—Set during compromise recovery
migration_errortextNo—Set on re-encryption failure
created_atdatetimeNo——
updated_atdatetimeNo——

SecretType​

Defines the type of a secret — a UI hint that drives how the interface labels and presents fields.

PropertyTypeNotes
idUUIDPrimary key
namestringSlug identifier (e.g., api_key) — unique
labelstringHuman-readable (e.g., "API Key")
scopeenum: system, user, globalsystem = built-in; user = per-user; global = admin-created
owner_idstringNextcloud user ID for user scope; null for system/global
created_atdatetime—

System types (seeded on install, immutable): login, api_key, ssh_key, certificate, note, database

Folder​

Organizes secrets into a per-user tree hierarchy.

PropertyTypeNotes
idUUIDPrimary key
namestringSingle path segment, no slashes
parent_idFK (Folder)Null = root level
owner_typeenum: user, applicationPolymorphic ownership
owner_idstringNextcloud user ID or Application ID
created_atdatetime—
updated_atdatetime—

Application​

An external or internal application registered to use Keepiq as a secret store.

PropertyTypeNotes
idUUIDPrimary key
namestringHuman-readable name
typeenum: internal, externalInformational; no functional difference currently
statusenum: pending, activeNon-admin registrations start as pending
registered_bystringNextcloud user ID or null (anonymous)
approved_bystringAdmin user ID; null if pending
created_atdatetime—
approved_atdatetime—

SecretShare (User-to-User)​

Links an original secret to an encrypted copy in a recipient's vault.

PropertyTypeNotes
idUUIDPrimary key
source_secret_idFK (Secret)The original secret
target_user_idstringRecipient's Nextcloud user ID
secret_idFK (Secret)The encrypted copy in recipient's vault
group_share_idFK (GroupShare)Nullable — set if created from a group share
created_atdatetime—

GroupShare​

Tracks that a secret has been shared with a Nextcloud user group.

PropertyTypeNotes
idUUIDPrimary key
secret_idFK (Secret)The secret shared with the group
group_idstringNextcloud group ID
created_bystringOwner who created the share
created_atdatetime—

SecretDelegation​

Tracks ownership delegation of a secret.

PropertyTypeNotes
idUUIDPrimary key
secret_idFK (Secret)The delegated secret
original_owner_idstringOriginal owner's user ID
delegated_tostringDelegate's user ID
delegated_atdatetime—
initiated_bystringWho created the delegation
is_permanentboolFalse = temporary; true = permanent (owner's suite revoked)
made_permanent_atdatetimeNull while temporary

LinkShare​

A password-protected link for sharing a secret snapshot with external parties.

PropertyTypeEncryptedNotes
idUUIDNoPrimary key
secret_idFKNoThe secret being shared
tokenstringNoURL-safe random token (≥128 bits entropy)
encrypted_secret_snapshottextYes (AES-256 via Argon2id KDF)Point-in-time snapshot
encryption_suite_idFKNoSuite used to encrypt the snapshot
usage_limitintNoMax accesses; null = unlimited
usage_countintNoCurrent access count
created_atdatetimeNo—
expires_atdatetimeNoOptional expiry

Security: Link password is never stored. Snapshot is encrypted with AES-256 using a key derived from the link password via Argon2id (RFC 9106). After 5 consecutive failed attempts, the link share is permanently deleted.

SecretRequest​

A fill-in link for write-without-read secret submission.

PropertyTypeNotes
idUUIDPrimary key
secret_idFK (Secret)The unfilled Secret this request writes to
encryption_suite_idFKPublic certificate used to encrypt submitted values
tokenstringURL-safe random token (≥128 bits entropy)
requested_fieldsJSONArray of field names being requested
statusenum: pending, locked, fulfilledlocked during compromise recovery
expires_atdatetimeOptional expiry
created_atdatetime—
fulfilled_atdatetime—

SuiteMigration​

Tracks compromise recovery migrations.

PropertyTypeNotes
idUUIDPrimary key
old_suite_idFK (EncryptionSuite)Compromised suite
new_suite_idFK (EncryptionSuite)Replacement suite
statusenum: in_progress, completed, completed_with_errorsMigration state
started_atdatetime—
completed_atdatetimeNull while in progress

Emergency-access recovery envelopes are a migrated store, not a casualty. A compromise-recovery rotation re-encrypts every suite-bound store under the new key; emergency contacts are the one store not produced by decrypt-then-re-encrypt. The rotating owner holds the new private key and can fetch each grantee's current certificate, so the browser mints a fresh recovery envelope escrowing the new key (buildRecoveryEnvelope — a build, never a re-wrap of the old envelope) and re-points the contact to the new suite, keeping it granted (MigrationController::reEnvelopeEmergencyContact → EmergencyEnvelopeInvalidationService::reEnvelopeForRotation). The server cannot open the envelope (only the grantee can), so it shape-checks it and asserts the declared grantee suite is the grantee's current active suite rather than round-tripping it. Emergency contacts are deliberately outside the completion gate: a grantee with no reachable certificate can never be re-enveloped, and gating on one would wedge the vault, so such a contact is left on the old suite.

EmergencyEnvelopeInvalidationService::invalidateForGrantorRotation, fired by EmergencyAccessSuiteRotationListener on SuiteMigrationCompletedEvent, is now a residual sweep, not a blanket invalidation: the re-enveloped contacts have already left the old suite, so the sweep finds only the contacts that weren't carried: unreachable grantees, contacts the owner didn't tick (or declined), and contacts with a break-glass in flight. It invalidates exactly those, recording grantor_rotation_in_flight for the last group and grantor_rotation for the rest. The recovery form prompts re-establishing only an unreachable contact. A resumed rotation reads the removed contacts back and names them without a prompt, and the Emergency Access view shows a text-only notice on each. Revocation still clears the envelopes outright — it produces no new key to migrate to — but EncryptionSuiteController::revoke now refuses while a usable emergency contact exists unless acceptEmergencyLoss is given, and the refusal surfaces the count (never the identities) so the destruction is a knowing choice.

3.3 Encryption Flow Summary​

Master Password (user input, never stored)
│
▼
┌─────────────────┐
│ KDF (derive) │
└────────┬────────┘
│
▼
AES-Derived Key (stored in session only)
│
┌─────────┴─────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Decrypt │ │ Encrypt │
│ Private Key │ │ Private Key │
│ (AES-256) │ │ (AES-256) │
└───────┬───────┘ └───────────────┘
│
▼
Private Key (in memory only)
│
┌─────────┴─────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Decrypt │ │ Encrypt │
│ Secrets │ │ Secrets │
│ (RSA-4096) │ │ (RSA-4096) │
└───────────────┘ └───────────────┘

Write-without-read property: Because encryption uses the owner's public certificate (available to anyone), any party can write a secret into another user's or application's vault. Only the private key holder can decrypt — and the private key requires the master password. This is a direct consequence of asymmetric cryptography and is fundamental to secret requests and application secret management.

3.4 Nextcloud Integration Strategy​

Principle: Keepiq handles all encryption and secret storage itself. Nextcloud provides identity, session, notification, and search infrastructure.

REUSE from Nextcloud​

FeatureOCP InterfaceWhat to ReuseHow
UsersOCP\IUserManagerAuthentication identity, vault ownershipReference by Nextcloud user UID
GroupsOCP\IGroupManagerGroup sharing, instance admin checkQuery group membership for group shares
SessionOCP\ISessionStore AES-derived key during vault sessionISession::set('keepiq_aes_key', $derivedKey)
NotificationsOCP\Notification\IManagerShare received, request fulfilled, CA expiry, app approvalImplement INotifier for rendering
SearchOCP\Search\IProviderUnified search (Ctrl+F) for secrets by name/URLQuery name + url without AES key; deep-link to secret
SettingsOCP\Settings\ISettingsAdmin settings panel (CA health, password policy)Register admin section
App ConfigOCP\IAppConfigApp-wide settings (min password length, session timeout)Store admin-configurable values
User ConfigOCP\IConfigPer-user settings (session timeout preference, notification toggles)Store user preferences
User DeletionOCP\User\Events\UserDeletedEventClean up EncryptionSuite, secrets, shares on user deleteEvent listener
Group MembershipOCP\Group\Events\UserAddedEvent / UserRemovedEventTrigger group share add/revokeEvent listeners

BUILD in Keepiq (Security-specific)​

WhatWhy Not Reuse
EncryptionSuites & CACore security model — no Nextcloud equivalent
Secret storage (encrypted)Field-level encryption with per-user keys — cannot use generic storage
Master password sessionCustom session management with configurable timeout and tab-close detection
Key generatorBrowser-side randomness (crypto.getRandomValues) in src/generator/generator.js, shared by the web app and the extension, with configurable rules, passphrases and the org policy clamp; the server never sees a generated value
Sharing (user/link/request)Encryption-aware sharing — each share is a re-encrypted copy
Suite migrationCompromise recovery with re-encryption — domain-specific
Application managementCSR processing, approval queue — domain-specific PKI

Key OCP Interface Examples​

// Session — store AES-derived key
$session = \OCP\Server::get(\OCP\ISession::class);
$session->set('keepiq_aes_key', $aesKey);
$session->get('keepiq_aes_key'); // retrieve for decryption

// Notifications — secret shared
$manager = \OCP\Server::get(\OCP\Notification\IManager::class);
$notification = $manager->createNotification();
$notification->setApp('keepiq')
->setUser($recipientId)
->setSubject('secret_shared', ['sharer' => $sharerId])
->setObject('secret', $secretId);
$manager->notify($notification);

// Search — register unified search provider
class SecretSearchProvider implements \OCP\Search\IProvider {
public function search(\OCP\IUser $user, \OCP\Search\ISearchQuery $query): SearchResult {
// Query name + url columns directly — no AES key needed
$secrets = $this->secretMapper->searchByNameOrUrl($user->getUID(), $query->getTerm());
// Return results with deep-link URLs (via lock screen if session inactive)
}
}

// User deletion cleanup
class UserDeletedListener implements \OCP\EventDispatcher\IEventListener {
public function handle(\OCP\EventDispatcher\Event $event): void {
// Delete user's EncryptionSuite, secrets, shares, folders
}
}

// Group membership — auto-revoke group shares
class UserRemovedFromGroupListener implements \OCP\EventDispatcher\IEventListener {
public function handle(\OCP\EventDispatcher\Event $event): void {
// Delete all SecretShares where group_share_id references a GroupShare for this group
}
}

3.5 @conduction/nextcloud-vue Library​

Keepiq uses its own database (not OpenRegister), so store-level components (useObjectStore, plugins) do not apply. However, the following UI components and patterns from @conduction/nextcloud-vue are used:

LayerWhat to UsePurpose
ComponentsCnDataTable, CnFilterBar, CnStatusBadge, CnEmptyState, CnPaginationConsistent list views for secrets, applications, shares
SettingsCnSettingsSection, CnVersionInfoCard, CnSettingsCardAdmin settings page (CA health, password policy, app management)
Detail pagesCnDetailPage, CnDetailCard, CnObjectSidebarSecret detail view with card-based layout + sidebar (files, notes, tags, audit trail)
CSSNL Design System double-fallback pattern (cn- prefix)Government theming compliance
Not useduseObjectStore, registerMappingPlugin, lifecyclePlugin, useListView, useDetailViewThese depend on OpenRegister API — Keepiq has its own backend API

Keepiq implements its own Pinia stores (useSecretStore, useEncryptionSuiteStore, useApplicationStore, etc.) that call Keepiq's REST API instead of the OpenRegister API. The stores follow the same patterns (loading state, pagination, CRUD) but are not derived from useObjectStore.

package.json dependency (MUST be present):

"@conduction/nextcloud-vue": "^0.1.0-beta.1"

Webpack alias (MUST be in webpack.config.js):

const fs = require('fs')
const localLib = path.resolve(__dirname, '../nextcloud-vue/src')
const useLocalLib = fs.existsSync(localLib)

// In resolve.alias:
...(useLocalLib ? { '@conduction/nextcloud-vue': localLib } : {}),
'vue$': path.resolve(__dirname, 'node_modules/vue'),
'pinia$': path.resolve(__dirname, 'node_modules/pinia'),
'@nextcloud/vue$': path.resolve(__dirname, 'node_modules/@nextcloud/vue'),

3.6 Vue Router (Navigation)​

All navigation uses Vue Router (hash mode). The lock screen is a route guard — if no AES key is in session, all routes redirect to the lock screen.

PathNameComponentProps
/DashboardDashboard—
/lockLockLockScreenroute => ({ returnUrl: route.query.returnUrl })
/secretsSecretsSecretList—
/secrets/:idSecretDetailSecretDetailroute => ({ secretId: route.params.id })
/folders/:idFolderViewSecretListroute => ({ folderId: route.params.id })
/applicationsApplicationsApplicationList—
/applications/:idApplicationDetailApplicationDetailroute => ({ applicationId: route.params.id })
/share/link/:tokenLinkShareAccessLinkShareAccessroute => ({ token: route.params.token })
/share/request/:tokenSecretRequestFillSecretRequestFillroute => ({ token: route.params.token })
*—redirect → /—

Route guard pattern:

router.beforeEach((to, from, next) => {
// Public routes (link share, request fill-in) skip the lock screen
if (['LinkShareAccess', 'SecretRequestFill', 'Lock'].includes(to.name)) {
return next()
}
// Check if AES key is in session (API call to backend)
if (!store.isUnlocked) {
return next({ name: 'Lock', query: { returnUrl: to.fullPath } })
}
next()
})

Key files: src/router/index.js, registered in main.js, <router-view /> in App.vue. MainMenu navigation: use :to prop on NcAppNavigationItem (NOT @click + $router.push()).

The MainMenu footer contains two items: "Lock vault" (lock icon — calls session.lock() and navigates to /lock) and "Settings" (gear icon — opens NcAppSettingsDialog for user preferences).

3.7 OpenConnector Integration​

Keepiq acts as a secret store for OpenConnector. When OpenConnector needs API keys or credentials to connect to external services, it can retrieve them from Keepiq's application vault via the API.

Integration pattern:

  1. Register OpenConnector as an Application in Keepiq (admin auto-approved)
  2. OpenConnector receives an EncryptionSuite (via generated key pair or CSR)
  3. Admin writes API keys/credentials into OpenConnector's application vault
  4. OpenConnector retrieves its secrets via Keepiq's API using X-Vault-Password header

This integration is via Keepiq's REST API — no tight coupling or shared database.

4. Database Configuration​

Doctrine Entities​

All entities use Nextcloud's ISchemaWrapper migration pattern. Migrations are stored in lib/Migration/.

EntityTable NameNotes
EncryptionSuitekeepiq_encryption_suitesComposite index on (owner_type, owner_id)
CACertificatekeepiq_ca_certificates—
Secretkeepiq_secretsIndex on (owner_type, owner_id), folder_id, encryption_suite_id
SecretTypekeepiq_secret_typesUnique index on name
Folderkeepiq_foldersIndex on (owner_type, owner_id, parent_id)
Applicationkeepiq_applications—
SecretSharekeepiq_secret_sharesIndex on source_secret_id, target_user_id
GroupSharekeepiq_group_sharesIndex on (secret_id, group_id)
SecretDelegationkeepiq_secret_delegationsIndex on secret_id
LinkSharekeepiq_link_sharesUnique index on token
SecretRequestkeepiq_secret_requestsUnique index on token
SuiteMigrationkeepiq_suite_migrations—

4.1 Public endpoint rate limits​

Every controller method registered with #[PublicPage] (reachable without a Nextcloud session) also carries a Nextcloud-native #[AnonRateLimit(limit: N, period: 60)] attribute. Limits are sized per endpoint sensitivity — tighter for the highest-value credential-custody targets, looser for polling machine clients — and are intentionally documented here so a future PR loosening one has to consciously edit documented intent rather than silently drop a limit during refactoring.

Controller::methodLimitRationale
ApplicationTokenController::exchange10 / 60sJWT-bearer token exchange (POST /api/v1/token) is the single highest-value target — a successful forced/guessed exchange yields a bearer token good for reading an application's secrets. Legitimate callers retry infrequently.
LinkShareAccessController::show15 / 60sPhase 1 of the public link-share access protocol. Composes with the existing recordFailedAttempt domain-level brute-force counter (auto-delete after N failed attempts): the rate limit rejects with 429 on an over-limit burst before the domain counter increments, so a rate-limited request is never double-counted as a "failed attempt".
LinkShareAccessController::confirm15 / 60sPhase 2 confirmation of a successful client-side decryption. Legitimate recipients make a handful of requests per session, not bursts.
SecretRequestFillController::show20 / 60sPublic token-based recipient fill-in flow, phase 1 (resolve token metadata).
SecretRequestFillController::fill20 / 60sFill-in flow phase 2 (submit encrypted fields).
ApplicationSecretsController::index30 / 60sBearer-authenticated but the route is #[PublicPage] so anonymous traffic reaches the controller before JwtAuthMiddleware validates the header. Limit protects the pre-auth surface; generous enough for a legitimate polling machine client.
ApplicationSecretsController::show30 / 60sSame rationale as index.
ApplicationController::create10 / 60sAnonymous application self-registration — only reachable when an admin opts in via anonymous_application_registration_enabled. Admins enabling anonymous registration inherit this rate limit.
MachineLeaseController::index30 / 60sBearer-authenticated lease list (machine-secret-leases §4.1); #[PublicPage] pre-auth surface, same polling profile as the secrets endpoints.
MachineLeaseController::revoke30 / 60sLease self-revocation — rare in legitimate use; the limit caps abuse of the rotation-flag side effect.
EphemeralSendAccessController::peek15 / 60sAnonymous ephemeral-send metadata (ephemeral-send §4.2); the ≥256-bit token is the real access control, the limit caps enumeration attempts.
EphemeralSendAccessController::access15 / 60sCiphertext fetch phase of the two-phase protocol; a view is only consumed on confirm.
EphemeralSendAccessController::confirm15 / 60sDecrypt confirmation (consumes a view, burns at cap).
EphemeralSendAccessController::failure15 / 60sFailed-password reporting (burns the send at 5); the limit caps deliberate burn-griefing bursts.

All limits are keyed anonymously (per-IP) by Nextcloud's rate-limiter middleware, which is available since NC 24; Keepiq's info.xml floor (NC 31) already satisfies this.

4.2 Vault-key proofs on destructive operations​

The always-E2E model (ADR-003) makes reading the vault cryptographically gated on the master password, but leaves writing gated only by the Nextcloud session, because writing a secret needs only the owner's public key. For the operations that can render vault contents or key material permanently unreadable, a session is not enough: those carry a vault-key proof — a signature, made with the owner's suite private key, over a server-issued challenge bound to the operation's own parameters. Because that private key is recoverable only by decrypting its envelope with the master password, a verified proof is a server-verifiable proof of the master password. This closes the session-only lockout (issue #395): a stolen cookie, leaked app password, or XSS in an unlocked tab can no longer destroy a vault.

Enforced declaratively by #[VaultKeyProofRequired(binds, subject, purpose)] (lib/Attribute/) + VaultKeyProofMiddleware, with the crypto in VaultKeyProofService. The guarded routes:

Controller::methodsubjectbindspurpose
EncryptionSuiteController::compromiseRecoveryactive (old suite)publicKey, encryptedPrivateKeycompromise-recovery
EncryptionSuiteController::updatePrivateKeyrouteParam:idencryptedPrivateKeyupdate-private-key
EncryptionSuiteController::revokerouteParam:idreason, acceptEmergencyLossrevoke-suite
MigrationController::completemigrationOldSuiteid, hasErrors, acceptUnrecoverablecomplete-migration
MigrationController::abortmigrationNewSuiteidabort-migration
MigrationController::reEnvelopeEmergencyContactmigrationNewSuiteid, contactId, recoveryEnvelope, granteeSuiteIdemergency-access-re-envelope
EmergencyAccessController::create (designate)activegranteeUserId, waitPeriodDays, recoveryEnvelopeemergency-access-designate
EmergencyAccessController::destroyactiveidemergency-access-destroy
GdprController::deleteAccountDataactiveconfirmationdelete-account-data

This table is generated from the attributes themselves (grep -rn "VaultKeyProofRequired(" lib/Controller) and VaultKeyProofAttributesTest pins every row. updatePrivateKey is also refused while the suite is part of an open migration or not active (keepiq#869), because during a compromise recovery the old password may be the leaked one.

Load-bearing design points — change these only deliberately:

  • Sign, never decrypt. The proof is a signature. A decrypt challenge would be satisfiable by the session CryptoKey, which is imported non-extractable and ['decrypt']-only — so an unlocked tab (and thus injected script) could answer it. Signing needs the raw private key, re-imported with ['sign'] from bytes that exist only while the freshly entered master password is in hand.
  • The attribute carries the binding. The middleware cannot read the request body (the framework decodes JSON and drops the raw bytes), so the proof commits to named request parameters, each hashed and concatenated in declared order. No JSON-canonicalisation agreement between JS and PHP is needed; cross-language interop is pinned by VaultKeyProofCrossImplTest.
  • Stateless, expiring challenges; single-use proofs. The nonce is HMAC-authenticated with the instance secret over its random part, the caller, the purpose and an expiry, so issuing and checking it needs no store. Binding to the operation's parameters is not enough on its own: on an upsert route a replayed designate proof would recreate a contact the owner just revoked. So once a proof verifies, a hash of its nonce is inserted into keepiq_used_proofs, unique on that hash, and a second use hits the index and is refused (keepiq#868). The database holds on every install: without a memcache, with APCu alone, and across cluster nodes. A cache did not. If the insert fails for any other reason the proof is refused. Expired rows are swept on each use. A nonce is never consumed before its proof verifies.
  • Every refusal is recorded. A refused proof is logged and written to the audit trail as key_proof.refused (route, purpose, reason, never the proof), because a session thief probing guarded routes produces exactly that.
  • Not waived for any session type. The middleware consults no auth backend and no token scope, so it behaves identically on SSO, app-password and ordinary sessions — its authority is key material, not the login method.
  • complete proves the OLD key (migrationOldSuite), not the new one: at completion both suites are active so active is ambiguous, and the old key is the one both the initiate and resume clients already hold the password for.
  • Abort proves the NEW key (migrationNewSuite, keepiq#859). A stolen session could otherwise abort the owner's recovery every time it started, and the old password may be the leaked one. Only whoever holds the key the rotation moves to can call it off. A rotation started by someone else with a leaked old password is contained by the administrator's compromise force-revoke, which ends the migration. Every abort is audited as suite.recovery_aborted.

A new route that can irreversibly destroy vault data MUST be added to tests/Unit/Controller/VaultKeyProofAttributesTest.php. A declarative guard fails open when it is omitted — nothing errors, the attribute is just absent — so that reflection test enumerates the guarded routes and fails the build if one loses its attribute or has its binding/subject/purpose loosened. The test also carries a documented exclusion list:

  • EncryptionSuiteController::proofChallenge: issuing a challenge grants nothing on its own, and guarding it would be circular.
  • EncryptionSuiteController::forceRevoke: an administrator holds no vault key (zero-knowledge), so a proof cannot be produced. It is guarded by the admin check plus Nextcloud's password confirmation (sudo) instead.

The master password is the only key to an owner's own revoke. An owner who has lost it cannot sign the proof, so the locked-out owner's revoke path is an administrator's force-revoke.

5. Open Research Questions​

  1. Application API authentication — RFC 7523 (JWT Bearer / Private Key JWT) is the lean for how approved applications authenticate to retrieve secrets. Uses existing RSA key infrastructure, short-lived tokens, no new credential. Needs team discussion before finalizing. See application-mgmt spec.

  2. Pagination approach — 50 items per page (standard pagination) or 30 items with dynamic infinite scroll? To be decided during UI design.

  3. Subfolder cascade — Does ?cascade=delete and ?cascade=move apply recursively to subfolders, or only to direct contents? Unclear whether recursive folder deletion should be allowed.

  4. Internal application master password — How does a Nextcloud app running a cronjob authenticate to its Keepiq vault? No solution found that doesn't compromise the security model. See Vault-app.docx for full analysis.

  5. Post-quantum cryptography — RSA-4096 is vulnerable to future quantum attacks. Post-quantum algorithms are not yet available in stable PHP/OpenSSL. Must be revisited when PHP gains support.

  6. Forced intermediate revocation and secret compromise — When an admin force-revokes the intermediate certificate (e.g., leaked key), should all secrets be flagged possibly_compromised_at? The intermediate signs certificates but doesn't directly encrypt secrets. Needs further analysis.

6. References​

Cryptographic Standards​

Key Management & Password Policy​

Nextcloud OCP Interfaces​

Architectural Decisions​