mindoodb 0.0.24 → 0.0.32
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-server.md +9 -1
- package/README.md +5 -3
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +1 -1
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +24 -5
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.d.ts +14 -2
- package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.js +124 -27
- package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +159 -4
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +334 -25
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +9 -0
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +77 -5
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
- package/dist/browser/index.mjs +103 -0
- package/dist/core/BaseMindooDB.d.ts +662 -4
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +2769 -144
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/BaseMindooTenant.d.ts +192 -1
- package/dist/core/BaseMindooTenant.d.ts.map +1 -1
- package/dist/core/BaseMindooTenant.js +633 -88
- package/dist/core/BaseMindooTenant.js.map +1 -1
- package/dist/core/BaseMindooTenantDirectory.d.ts +770 -10
- package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
- package/dist/core/BaseMindooTenantDirectory.js +2715 -292
- package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
- package/dist/core/BaseMindooTenantFactory.d.ts +2 -0
- package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
- package/dist/core/BaseMindooTenantFactory.js +20 -0
- package/dist/core/BaseMindooTenantFactory.js.map +1 -1
- package/dist/core/accesscontrol/AccessDeniedError.d.ts +22 -0
- package/dist/core/accesscontrol/AccessDeniedError.d.ts.map +1 -0
- package/dist/core/accesscontrol/AccessDeniedError.js +28 -0
- package/dist/core/accesscontrol/AccessDeniedError.js.map +1 -0
- package/dist/core/accesscontrol/DirectoryStateNode.d.ts +220 -0
- package/dist/core/accesscontrol/DirectoryStateNode.d.ts.map +1 -0
- package/dist/core/accesscontrol/DirectoryStateNode.js +297 -0
- package/dist/core/accesscontrol/DirectoryStateNode.js.map +1 -0
- package/dist/core/accesscontrol/DirectoryTimeTravelIndex.d.ts +118 -0
- package/dist/core/accesscontrol/DirectoryTimeTravelIndex.d.ts.map +1 -0
- package/dist/core/accesscontrol/DirectoryTimeTravelIndex.js +173 -0
- package/dist/core/accesscontrol/DirectoryTimeTravelIndex.js.map +1 -0
- package/dist/core/accesscontrol/directoryProjection.d.ts +26 -0
- package/dist/core/accesscontrol/directoryProjection.d.ts.map +1 -0
- package/dist/core/accesscontrol/directoryProjection.js +174 -0
- package/dist/core/accesscontrol/directoryProjection.js.map +1 -0
- package/dist/core/accesscontrol/evaluate.d.ts +77 -0
- package/dist/core/accesscontrol/evaluate.d.ts.map +1 -0
- package/dist/core/accesscontrol/evaluate.js +206 -0
- package/dist/core/accesscontrol/evaluate.js.map +1 -0
- package/dist/core/accesscontrol/grantKeys.d.ts +113 -0
- package/dist/core/accesscontrol/grantKeys.d.ts.map +1 -0
- package/dist/core/accesscontrol/grantKeys.js +302 -0
- package/dist/core/accesscontrol/grantKeys.js.map +1 -0
- package/dist/core/accesscontrol/keyBagReconciler.d.ts +22 -0
- package/dist/core/accesscontrol/keyBagReconciler.d.ts.map +1 -0
- package/dist/core/accesscontrol/keyBagReconciler.js +3 -0
- package/dist/core/accesscontrol/keyBagReconciler.js.map +1 -0
- package/dist/core/accesscontrol/materializationGuard.d.ts +89 -0
- package/dist/core/accesscontrol/materializationGuard.d.ts.map +1 -0
- package/dist/core/accesscontrol/materializationGuard.js +69 -0
- package/dist/core/accesscontrol/materializationGuard.js.map +1 -0
- package/dist/core/accesscontrol/quarantine.d.ts +65 -0
- package/dist/core/accesscontrol/quarantine.d.ts.map +1 -0
- package/dist/core/accesscontrol/quarantine.js +79 -0
- package/dist/core/accesscontrol/quarantine.js.map +1 -0
- package/dist/core/accesscontrol/receiptValidation.d.ts +113 -0
- package/dist/core/accesscontrol/receiptValidation.d.ts.map +1 -0
- package/dist/core/accesscontrol/receiptValidation.js +142 -0
- package/dist/core/accesscontrol/receiptValidation.js.map +1 -0
- package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.d.ts +31 -0
- package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.d.ts.map +1 -0
- package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.js +66 -0
- package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.js.map +1 -0
- package/dist/core/accesscontrol/timestamp/TimestampProvider.d.ts +79 -0
- package/dist/core/accesscontrol/timestamp/TimestampProvider.d.ts.map +1 -0
- package/dist/core/accesscontrol/timestamp/TimestampProvider.js +22 -0
- package/dist/core/accesscontrol/timestamp/TimestampProvider.js.map +1 -0
- package/dist/core/accesscontrol/types.d.ts +825 -0
- package/dist/core/accesscontrol/types.d.ts.map +1 -0
- package/dist/core/accesscontrol/types.js +534 -0
- package/dist/core/accesscontrol/types.js.map +1 -0
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +9 -0
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +54 -0
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
- package/dist/core/appendonlystores/network/AuthenticationService.d.ts +38 -2
- package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/AuthenticationService.js +199 -26
- package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
- package/dist/core/appendonlystores/network/NetworkTransport.d.ts +17 -3
- package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/types.d.ts +58 -4
- package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/types.js +4 -0
- package/dist/core/appendonlystores/network/types.js.map +1 -1
- package/dist/core/appendonlystores/scanUtils.d.ts +27 -0
- package/dist/core/appendonlystores/scanUtils.d.ts.map +1 -0
- package/dist/core/appendonlystores/scanUtils.js +87 -0
- package/dist/core/appendonlystores/scanUtils.js.map +1 -0
- package/dist/core/appendonlystores/types.d.ts +23 -2
- package/dist/core/appendonlystores/types.d.ts.map +1 -1
- package/dist/core/cache/CacheManager.d.ts +13 -3
- package/dist/core/cache/CacheManager.d.ts.map +1 -1
- package/dist/core/cache/CacheManager.js +7 -4
- package/dist/core/cache/CacheManager.js.map +1 -1
- package/dist/core/crypto/EntrySignature.d.ts +137 -0
- package/dist/core/crypto/EntrySignature.d.ts.map +1 -0
- package/dist/core/crypto/EntrySignature.js +210 -0
- package/dist/core/crypto/EntrySignature.js.map +1 -0
- package/dist/core/crypto/MindooDocSigner.d.ts.map +1 -1
- package/dist/core/crypto/MindooDocSigner.js +6 -2
- package/dist/core/crypto/MindooDocSigner.js.map +1 -1
- package/dist/core/crypto/WitnessReceipt.d.ts +137 -0
- package/dist/core/crypto/WitnessReceipt.d.ts.map +1 -0
- package/dist/core/crypto/WitnessReceipt.js +182 -0
- package/dist/core/crypto/WitnessReceipt.js.map +1 -0
- package/dist/core/crypto/encryptedFields.d.ts +27 -0
- package/dist/core/crypto/encryptedFields.d.ts.map +1 -0
- package/dist/core/crypto/encryptedFields.js +58 -0
- package/dist/core/crypto/encryptedFields.js.map +1 -0
- package/dist/core/crypto/pbkdf2Iterations.d.ts +12 -0
- package/dist/core/crypto/pbkdf2Iterations.d.ts.map +1 -1
- package/dist/core/crypto/pbkdf2Iterations.js +23 -0
- package/dist/core/crypto/pbkdf2Iterations.js.map +1 -1
- package/dist/core/crypto/privateKeyEncryption.d.ts.map +1 -1
- package/dist/core/crypto/privateKeyEncryption.js +3 -1
- package/dist/core/crypto/privateKeyEncryption.js.map +1 -1
- package/dist/core/databaseIdValidation.d.ts +1 -1
- package/dist/core/databaseIdValidation.d.ts.map +1 -1
- package/dist/core/databaseIdValidation.js +4 -4
- package/dist/core/databaseIdValidation.js.map +1 -1
- package/dist/core/errors.d.ts +26 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +42 -1
- package/dist/core/errors.js.map +1 -1
- package/dist/core/expressions/builder.d.ts +102 -0
- package/dist/core/expressions/builder.d.ts.map +1 -0
- package/dist/core/expressions/builder.js +249 -0
- package/dist/core/expressions/builder.js.map +1 -0
- package/dist/core/expressions/evaluateExpression.d.ts +78 -0
- package/dist/core/expressions/evaluateExpression.d.ts.map +1 -0
- package/dist/core/expressions/evaluateExpression.js +386 -0
- package/dist/core/expressions/evaluateExpression.js.map +1 -0
- package/dist/core/expressions/formulaSource.d.ts +24 -0
- package/dist/core/expressions/formulaSource.d.ts.map +1 -0
- package/dist/core/expressions/formulaSource.js +765 -0
- package/dist/core/expressions/formulaSource.js.map +1 -0
- package/dist/core/expressions/index.d.ts +8 -0
- package/dist/core/expressions/index.d.ts.map +1 -0
- package/dist/core/expressions/index.js +46 -0
- package/dist/core/expressions/index.js.map +1 -0
- package/dist/core/expressions/metadata.d.ts +23 -0
- package/dist/core/expressions/metadata.d.ts.map +1 -0
- package/dist/core/expressions/metadata.js +191 -0
- package/dist/core/expressions/metadata.js.map +1 -0
- package/dist/core/expressions/types.d.ts +164 -0
- package/dist/core/expressions/types.d.ts.map +1 -0
- package/dist/core/expressions/types.js +3 -0
- package/dist/core/expressions/types.js.map +1 -0
- package/dist/core/extraction/types.d.ts +47 -0
- package/dist/core/extraction/types.d.ts.map +1 -0
- package/dist/core/extraction/types.js +42 -0
- package/dist/core/extraction/types.js.map +1 -0
- package/dist/core/index.d.ts +13 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +113 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/indexing/createViewDataProvider.d.ts +84 -0
- package/dist/core/indexing/createViewDataProvider.d.ts.map +1 -0
- package/dist/core/indexing/createViewDataProvider.js +139 -0
- package/dist/core/indexing/createViewDataProvider.js.map +1 -0
- package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts +180 -0
- package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts.map +1 -0
- package/dist/core/indexing/fulltext/DocumentFullTextIndex.js +548 -0
- package/dist/core/indexing/fulltext/DocumentFullTextIndex.js.map +1 -0
- package/dist/core/indexing/fulltext/SearchEngineAdapter.d.ts +69 -0
- package/dist/core/indexing/fulltext/SearchEngineAdapter.d.ts.map +1 -0
- package/dist/core/indexing/fulltext/SearchEngineAdapter.js +183 -0
- package/dist/core/indexing/fulltext/SearchEngineAdapter.js.map +1 -0
- package/dist/core/indexing/fulltext/extractFulltextText.d.ts +34 -0
- package/dist/core/indexing/fulltext/extractFulltextText.d.ts.map +1 -0
- package/dist/core/indexing/fulltext/extractFulltextText.js +129 -0
- package/dist/core/indexing/fulltext/extractFulltextText.js.map +1 -0
- package/dist/core/indexing/fulltext/index.d.ts +5 -0
- package/dist/core/indexing/fulltext/index.d.ts.map +1 -0
- package/dist/core/indexing/fulltext/index.js +20 -0
- package/dist/core/indexing/fulltext/index.js.map +1 -0
- package/dist/core/indexing/fulltext/types.d.ts +168 -0
- package/dist/core/indexing/fulltext/types.d.ts.map +1 -0
- package/dist/core/indexing/fulltext/types.js +89 -0
- package/dist/core/indexing/fulltext/types.js.map +1 -0
- package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +137 -0
- package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -0
- package/dist/core/indexing/summary/DocumentSummaryStore.js +438 -0
- package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -0
- package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.d.ts +72 -0
- package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.d.ts.map +1 -0
- package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js +221 -0
- package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js.map +1 -0
- package/dist/core/indexing/summary/extractSummaryFields.d.ts +75 -0
- package/dist/core/indexing/summary/extractSummaryFields.d.ts.map +1 -0
- package/dist/core/indexing/summary/extractSummaryFields.js +266 -0
- package/dist/core/indexing/summary/extractSummaryFields.js.map +1 -0
- package/dist/core/indexing/summary/index.d.ts +5 -0
- package/dist/core/indexing/summary/index.d.ts.map +1 -0
- package/dist/core/indexing/summary/index.js +23 -0
- package/dist/core/indexing/summary/index.js.map +1 -0
- package/dist/core/indexing/summary/types.d.ts +119 -0
- package/dist/core/indexing/summary/types.d.ts.map +1 -0
- package/dist/core/indexing/summary/types.js +81 -0
- package/dist/core/indexing/summary/types.js.map +1 -0
- package/dist/core/indexing/virtualviews/IVirtualViewDataProvider.d.ts +47 -1
- package/dist/core/indexing/virtualviews/IVirtualViewDataProvider.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts +24 -8
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +96 -41
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
- package/dist/core/indexing/virtualviews/ViewEntrySortKey.d.ts +2 -0
- package/dist/core/indexing/virtualviews/ViewEntrySortKey.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/ViewEntrySortKey.js +8 -3
- package/dist/core/indexing/virtualviews/ViewEntrySortKey.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualView.d.ts +111 -5
- package/dist/core/indexing/virtualviews/VirtualView.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualView.js +266 -75
- package/dist/core/indexing/virtualviews/VirtualView.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewColumn.d.ts +12 -0
- package/dist/core/indexing/virtualviews/VirtualViewColumn.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewColumn.js +1 -0
- package/dist/core/indexing/virtualviews/VirtualViewColumn.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts +12 -0
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.js +66 -5
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewFactory.d.ts +50 -6
- package/dist/core/indexing/virtualviews/VirtualViewFactory.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewFactory.js +54 -12
- package/dist/core/indexing/virtualviews/VirtualViewFactory.js.map +1 -1
- package/dist/core/indexing/virtualviews/index.d.ts +3 -3
- package/dist/core/indexing/virtualviews/index.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/index.js.map +1 -1
- package/dist/core/indexing/virtualviews/types.d.ts +6 -0
- package/dist/core/indexing/virtualviews/types.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/types.js +11 -3
- package/dist/core/indexing/virtualviews/types.js.map +1 -1
- package/dist/core/keys/KeyBag.d.ts +25 -0
- package/dist/core/keys/KeyBag.d.ts.map +1 -1
- package/dist/core/keys/KeyBag.js +37 -1
- package/dist/core/keys/KeyBag.js.map +1 -1
- package/dist/core/query/executeQuery.d.ts +18 -0
- package/dist/core/query/executeQuery.d.ts.map +1 -0
- package/dist/core/query/executeQuery.js +313 -0
- package/dist/core/query/executeQuery.js.map +1 -0
- package/dist/core/query/index.d.ts +5 -0
- package/dist/core/query/index.d.ts.map +1 -0
- package/dist/core/query/index.js +14 -0
- package/dist/core/query/index.js.map +1 -0
- package/dist/core/query/queryLive.d.ts +27 -0
- package/dist/core/query/queryLive.d.ts.map +1 -0
- package/dist/core/query/queryLive.js +104 -0
- package/dist/core/query/queryLive.js.map +1 -0
- package/dist/core/query/queryView.d.ts +156 -0
- package/dist/core/query/queryView.d.ts.map +1 -0
- package/dist/core/query/queryView.js +266 -0
- package/dist/core/query/queryView.js.map +1 -0
- package/dist/core/query/types.d.ts +121 -0
- package/dist/core/query/types.d.ts.map +1 -0
- package/dist/core/query/types.js +18 -0
- package/dist/core/query/types.js.map +1 -0
- package/dist/core/storeEntryTime.d.ts +57 -0
- package/dist/core/storeEntryTime.d.ts.map +1 -0
- package/dist/core/storeEntryTime.js +72 -0
- package/dist/core/storeEntryTime.js.map +1 -0
- package/dist/core/types.d.ts +1371 -34
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +13 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/uri/MindooURI.d.ts +53 -1
- package/dist/core/uri/MindooURI.d.ts.map +1 -1
- package/dist/core/uri/MindooURI.js +135 -0
- package/dist/core/uri/MindooURI.js.map +1 -1
- package/dist/core/utils/urlSafety.d.ts +46 -0
- package/dist/core/utils/urlSafety.d.ts.map +1 -0
- package/dist/core/utils/urlSafety.js +144 -0
- package/dist/core/utils/urlSafety.js.map +1 -0
- package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +14 -0
- package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
- package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +95 -4
- package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
- package/dist/node/server/CapabilityMatcher.d.ts +10 -0
- package/dist/node/server/CapabilityMatcher.d.ts.map +1 -1
- package/dist/node/server/CapabilityMatcher.js +14 -2
- package/dist/node/server/CapabilityMatcher.js.map +1 -1
- package/dist/node/server/MindooDBServer.d.ts.map +1 -1
- package/dist/node/server/MindooDBServer.js +117 -8
- package/dist/node/server/MindooDBServer.js.map +1 -1
- package/dist/node/server/PurgedDocRegistry.d.ts +32 -0
- package/dist/node/server/PurgedDocRegistry.d.ts.map +1 -0
- package/dist/node/server/PurgedDocRegistry.js +94 -0
- package/dist/node/server/PurgedDocRegistry.js.map +1 -0
- package/dist/node/server/ServerSync.d.ts.map +1 -1
- package/dist/node/server/ServerSync.js +5 -2
- package/dist/node/server/ServerSync.js.map +1 -1
- package/dist/node/server/SystemAdminAuth.d.ts +10 -0
- package/dist/node/server/SystemAdminAuth.d.ts.map +1 -1
- package/dist/node/server/SystemAdminAuth.js +25 -0
- package/dist/node/server/SystemAdminAuth.js.map +1 -1
- package/dist/node/server/TenantManager.d.ts +95 -1
- package/dist/node/server/TenantManager.d.ts.map +1 -1
- package/dist/node/server/TenantManager.js +382 -15
- package/dist/node/server/TenantManager.js.map +1 -1
- package/dist/node/server/config.d.ts.map +1 -1
- package/dist/node/server/config.js +5 -1
- package/dist/node/server/config.js.map +1 -1
- package/dist/node/server/types.d.ts +29 -3
- package/dist/node/server/types.d.ts.map +1 -1
- package/dist/node/server/types.js +12 -0
- package/dist/node/server/types.js.map +1 -1
- package/dist/node/server/validation.d.ts +6 -0
- package/dist/node/server/validation.d.ts.map +1 -1
- package/dist/node/server/validation.js +7 -1
- package/dist/node/server/validation.js.map +1 -1
- package/package.json +4 -3
- package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.d.ts +0 -8
- package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.d.ts.map +0 -1
- package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.js +0 -16
- package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.js.map +0 -1
- package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +0 -68
- package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +0 -1
- package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.js +0 -659
- package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.js.map +0 -1
- package/dist/node/attachments/writeAttachmentToFile.d.ts +0 -15
- package/dist/node/attachments/writeAttachmentToFile.d.ts.map +0 -1
- package/dist/node/attachments/writeAttachmentToFile.js +0 -73
- package/dist/node/attachments/writeAttachmentToFile.js.map +0 -1
|
@@ -3,7 +3,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.BaseMindooTenantDirectory = void 0;
|
|
4
4
|
const types_1 = require("./types");
|
|
5
5
|
const logging_1 = require("./logging");
|
|
6
|
+
const grantKeys_1 = require("./accesscontrol/grantKeys");
|
|
7
|
+
const types_2 = require("./accesscontrol/types");
|
|
8
|
+
const DirectoryTimeTravelIndex_1 = require("./accesscontrol/DirectoryTimeTravelIndex");
|
|
9
|
+
const directoryProjection_1 = require("./accesscontrol/directoryProjection");
|
|
10
|
+
const types_3 = require("./accesscontrol/types");
|
|
11
|
+
const evaluate_1 = require("./accesscontrol/evaluate");
|
|
12
|
+
const RSAEncryption_1 = require("./crypto/RSAEncryption");
|
|
13
|
+
const encryptedFields_1 = require("./crypto/encryptedFields");
|
|
6
14
|
const DIRECTORY_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
|
15
|
+
// How long the resolved storage-format floor (`requireMetadataSignatureSince`)
|
|
16
|
+
// is reused before re-reading the directory head. Short, because it is consulted
|
|
17
|
+
// on the entry-verification hot path; the cutoff itself changes very rarely.
|
|
18
|
+
const METADATA_SIGNATURE_CUTOFF_TTL_MS = 30 * 1000;
|
|
19
|
+
/** De-duplicated union of two string lists, preserving first-seen order. */
|
|
20
|
+
function unionStrings(existing, added) {
|
|
21
|
+
return Array.from(new Set([...existing, ...added]));
|
|
22
|
+
}
|
|
7
23
|
class BaseMindooTenantDirectory {
|
|
8
24
|
constructor(tenant, logger) {
|
|
9
25
|
this.directoryDB = null;
|
|
@@ -12,20 +28,63 @@ class BaseMindooTenantDirectory {
|
|
|
12
28
|
// Note: No recursion guard needed because directory DB is admin-only,
|
|
13
29
|
// so loading entries doesn't trigger validatePublicSigningKey recursively
|
|
14
30
|
this.trustedKeysCache = new Map();
|
|
15
|
-
// Mapping from grant document ID to
|
|
16
|
-
|
|
31
|
+
// Mapping from grant document ID to the signing public keys it currently
|
|
32
|
+
// grants. Revocation is expressed by removing keys from a grant document
|
|
33
|
+
// (docs/accesscontrol.md §6.5), so the set of trusted keys is recomputed as
|
|
34
|
+
// the union of all grant documents' current key arrays after each cache pass.
|
|
35
|
+
this.grantDocIdToSigningKeys = new Map();
|
|
17
36
|
// Best-effort reverse lookup cache for public signing key -> user details.
|
|
18
37
|
// Entries remain available even after revocation so historical UIs can still show identity labels.
|
|
19
38
|
this.userLookupCache = new Map();
|
|
39
|
+
// Remote-wipe directive index (docs/accesscontrol.md §6.5): signing public key
|
|
40
|
+
// targeted for wipe -> id of the admin-signed grant document carrying the
|
|
41
|
+
// directive. Self-contained key values survive key removal, so this stays
|
|
42
|
+
// populated even after the user is revoked by key-array removal.
|
|
43
|
+
this.wipeKeyToGrantDocId = new Map();
|
|
20
44
|
// Cache for settings documents
|
|
21
45
|
this.tenantSettingsCache = null;
|
|
22
46
|
this.dbSettingsCache = new Map();
|
|
23
47
|
// Cache for groups: key -> merged group data (key: lowercase groupName)
|
|
24
48
|
// We store merged data separately to avoid mutating MindooDoc objects.
|
|
25
49
|
this.groupsCache = new Map();
|
|
50
|
+
// Cache for key-distribution documents (acl_keydistribution_<keyId>), keyed by
|
|
51
|
+
// keyId. Folded incrementally in updateUnifiedCache (singleton per keyId,
|
|
52
|
+
// last-seen-wins; a deleted doc removes its entry). Powers the revoked-key
|
|
53
|
+
// resolver (server pull/push blacklist) and the KeyBag reconcile without
|
|
54
|
+
// re-scanning directory docs each call (docs/accesscontrol.md §13).
|
|
55
|
+
this.keyDistributionCache = new Map();
|
|
56
|
+
// Cache for app-distribution documents (acl_appdistribution_<appId>), keyed by
|
|
57
|
+
// appId. Folded incrementally in updateUnifiedCache (singleton per appId,
|
|
58
|
+
// last-seen-wins; a deleted doc removes its entry). Holds only the
|
|
59
|
+
// server-readable recipient hash lists so the per-user reconcile can compute
|
|
60
|
+
// membership (have / notHave) without re-scanning directory docs; the
|
|
61
|
+
// encrypted payload (version / appData) is read from the doc when needed
|
|
62
|
+
// (docs/accesscontrol.md §13).
|
|
63
|
+
this.appDistributionCache = new Map();
|
|
64
|
+
// Incremental cache of sync-setup-policy recipient hash lists + enforcement
|
|
65
|
+
// mode, maintained by updateUnifiedCache (mirrors appDistributionCache). The
|
|
66
|
+
// encrypted database id list is read from the doc when the per-user reconcile
|
|
67
|
+
// runs (getSyncSetupForCurrentUser → listSyncSetupPolicies).
|
|
68
|
+
this.syncSetupPolicyCache = new Map();
|
|
69
|
+
// Time-travel access-control state (docs/accesscontrol.md §8). Built from the
|
|
70
|
+
// revision-grain changefeed (`iterateChangeRevisionsSince`) so every directory
|
|
71
|
+
// revision becomes its own chain node, stamped by trusted time. Persisted to
|
|
72
|
+
// disk as a compact delta log via the tenant CacheManager. Decoupled from the
|
|
73
|
+
// legacy doc-grain caches above, which remain the source of truth for the
|
|
74
|
+
// existing "now" public methods.
|
|
75
|
+
this.timeTravel = null;
|
|
76
|
+
// Last in-memory changefeed cursor the time-travel chain was built against,
|
|
77
|
+
// used to skip the (potentially expensive) feed rebuild when the directory
|
|
78
|
+
// has not changed since the previous build.
|
|
79
|
+
this.lastTimeTravelChangeSeq = null;
|
|
26
80
|
// Unified cache cursor for all document types
|
|
27
81
|
this.unifiedCacheLastCursor = null;
|
|
28
82
|
this.lastDirectorySyncTimestamp = 0;
|
|
83
|
+
// Cached storage-format floor (`requireMetadataSignatureSince`) with re-entrancy
|
|
84
|
+
// guard; see getRequireMetadataSignatureSince.
|
|
85
|
+
this.metadataSignatureCutoffCache = undefined;
|
|
86
|
+
this.metadataSignatureCutoffCachedAt = 0;
|
|
87
|
+
this.resolvingMetadataSignatureCutoff = false;
|
|
29
88
|
this.tenant = tenant;
|
|
30
89
|
// Create logger if not provided (for backward compatibility)
|
|
31
90
|
this.logger =
|
|
@@ -41,19 +100,31 @@ class BaseMindooTenantDirectory {
|
|
|
41
100
|
if (!this.directoryDB.isAdminOnlyDb()) {
|
|
42
101
|
throw new Error("Directory database must be opened with adminOnlyDb=true for security");
|
|
43
102
|
}
|
|
103
|
+
// SDK-driven key-distribution reconcile on directory bring-up
|
|
104
|
+
// (docs/accesscontrol.md §13). Fire-and-forget and single-flight so it
|
|
105
|
+
// never blocks the first directory access. This is the cold-start /
|
|
106
|
+
// backup-restore safety net: KeyBag.load() is silent (no onChanges, no
|
|
107
|
+
// cursor bump), so a restored older bag that still holds revoked keys
|
|
108
|
+
// would otherwise go un-reconciled until the next network sync. Runs only
|
|
109
|
+
// once per directory object (the creation branch); the driver's own
|
|
110
|
+
// getDirectoryDB calls reuse the now-set instance and do not re-trigger.
|
|
111
|
+
void this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
|
|
44
112
|
}
|
|
45
113
|
return this.directoryDB;
|
|
46
114
|
}
|
|
47
|
-
async registerUser(userId, administrationPrivateKey, administrationPrivateKeyPassword, userDetails) {
|
|
115
|
+
async registerUser(userId, administrationPrivateKey, administrationPrivateKeyPassword, userDetails, label) {
|
|
48
116
|
this.logger.info(`Registering user: ${userId.username}`);
|
|
49
|
-
// Check if user with same username (case-insensitive) already
|
|
117
|
+
// Check if user with same username (case-insensitive) already has ACTIVE
|
|
118
|
+
// access. A grant whose keys were removed (revocation, §6.5) is ignored
|
|
119
|
+
// here so the user can be re-registered after being revoked.
|
|
50
120
|
const existingDocs = await this.findGrantAccessDocuments(userId.username);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
121
|
+
const activeDocs = existingDocs.filter((doc) => (0, grantKeys_1.extractSigningPublicKeys)(doc.getData()).length > 0);
|
|
122
|
+
if (activeDocs.length > 0) {
|
|
123
|
+
// Check if the keys match the existing active registration
|
|
124
|
+
const existingDoc = activeDocs[activeDocs.length - 1]; // Use most recent
|
|
54
125
|
const existingData = existingDoc.getData();
|
|
55
|
-
const keysMatch = existingData.
|
|
56
|
-
existingData.
|
|
126
|
+
const keysMatch = (0, grantKeys_1.extractSigningPublicKeys)(existingData).includes(userId.userSigningPublicKey) &&
|
|
127
|
+
(0, grantKeys_1.extractEncryptionPublicKeys)(existingData).includes(userId.userEncryptionPublicKey);
|
|
57
128
|
if (keysMatch) {
|
|
58
129
|
// Same user with same keys - skip re-registration
|
|
59
130
|
this.logger.debug(`User ${userId.username} already registered with same keys, skipping`);
|
|
@@ -72,9 +143,22 @@ class BaseMindooTenantDirectory {
|
|
|
72
143
|
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
73
144
|
privateKey: administrationPrivateKey,
|
|
74
145
|
};
|
|
75
|
-
// Compute username hash
|
|
76
|
-
|
|
146
|
+
// Compute username hash (salted v2 for new documents) and encrypted user
|
|
147
|
+
// details before creating the document.
|
|
148
|
+
const usernameHash = await this.hashUsernameForWrite(userId.username);
|
|
77
149
|
const userDetailsEncrypted = await this.encryptUserDetailsForTenant(this.buildUserDetailsPayload(userId.username, userDetails));
|
|
150
|
+
// Precompute the identity-hash bundle from the cleartext name (the only
|
|
151
|
+
// place it exists) so the server can match wildcard/group read rules in
|
|
152
|
+
// hash space without the cleartext (docs/accesscontrol.md §6.5). The bundle
|
|
153
|
+
// is the v1+v2 hashes of every DN-hierarchy variant: e.g. for
|
|
154
|
+
// "CN=Karsten Lehmann/OU=CEO/OU=Management/O=Mindoo" the variants are the
|
|
155
|
+
// exact name plus the wildcards
|
|
156
|
+
// "*/OU=CEO/OU=Management/O=Mindoo",
|
|
157
|
+
// "*/OU=Management/O=Mindoo",
|
|
158
|
+
// "*/O=Mindoo", and
|
|
159
|
+
// "*"
|
|
160
|
+
// so a read rule like "*/O=Mindoo" matches this user purely in hash space.
|
|
161
|
+
const identityHashes = await this.computeIdentityHashes(userId.username);
|
|
78
162
|
// Add user to directory database
|
|
79
163
|
this.logger.debug(`Creating document for user registration`);
|
|
80
164
|
const directoryDB = await this.getDirectoryDB();
|
|
@@ -89,9 +173,28 @@ class BaseMindooTenantDirectory {
|
|
|
89
173
|
data.form = "useroperation";
|
|
90
174
|
data.type = "grantaccess";
|
|
91
175
|
data.username_hash = usernameHash;
|
|
176
|
+
data.username_hash_v = BaseMindooTenantDirectory.USERNAME_HASH_VERSION;
|
|
92
177
|
data.user_details_encrypted = userDetailsEncrypted;
|
|
93
|
-
|
|
94
|
-
|
|
178
|
+
// Follow the `_encrypted` / `_encrypted_key` convention so view formulas
|
|
179
|
+
// (v.decryptJson) can resolve the key id without hardcoding "default".
|
|
180
|
+
data.user_details_encrypted_key = types_1.DEFAULT_TENANT_KEY_ID;
|
|
181
|
+
// $publicinfos-readable identity-hash bundle (§6.5): the grant doc is
|
|
182
|
+
// encrypted under PUBLIC_INFOS_KEY_ID, so these plain fields are
|
|
183
|
+
// server-readable without the default tenant key.
|
|
184
|
+
data.identity_hashes = identityHashes;
|
|
185
|
+
data.identity_hashes_v = BaseMindooTenantDirectory.IDENTITY_VARIANTS_VERSION;
|
|
186
|
+
// New grants write the canonical `userKeyPairs` form (§6.5), pairing
|
|
187
|
+
// the device's signing+encryption keys with an optional label, while
|
|
188
|
+
// applyKeyPairFields mirrors the legacy array/scalar fields so older
|
|
189
|
+
// clients keep working. The admin can later add more devices or relabel.
|
|
190
|
+
const trimmedLabel = typeof label === "string" ? label.trim() : "";
|
|
191
|
+
const initialPair = {
|
|
192
|
+
signingPublicKey: userId.userSigningPublicKey,
|
|
193
|
+
encryptionPublicKey: userId.userEncryptionPublicKey,
|
|
194
|
+
};
|
|
195
|
+
if (trimmedLabel.length > 0)
|
|
196
|
+
initialPair.label = trimmedLabel;
|
|
197
|
+
(0, grantKeys_1.applyKeyPairFields)(data, [initialPair]);
|
|
95
198
|
}, {
|
|
96
199
|
signingKeyPair: adminSigningKeyPair,
|
|
97
200
|
signingKeyPassword: administrationPrivateKeyPassword,
|
|
@@ -110,88 +213,110 @@ class BaseMindooTenantDirectory {
|
|
|
110
213
|
await directoryDB.syncStoreChanges();
|
|
111
214
|
// Update unified cache to ensure we have latest data
|
|
112
215
|
await this.updateUnifiedCache();
|
|
113
|
-
// Compute the
|
|
114
|
-
|
|
216
|
+
// Compute the candidate hashes to search for. We match against BOTH the
|
|
217
|
+
// legacy (v1, unsalted) and salted (v2) forms so that documents written
|
|
218
|
+
// under either scheme are found (docs/accesscontrol.md §6.5).
|
|
219
|
+
const targetHashes = new Set(await this.usernameHashCandidates(username));
|
|
220
|
+
// Callers may pass an already-hashed `username_hash` rather than a cleartext
|
|
221
|
+
// name — e.g. when validateToken resolves the principal from a device key via
|
|
222
|
+
// getUserBySigningPublicKey, whose `username` is the hash when the cleartext
|
|
223
|
+
// is not recoverable on this server. Match it directly too so a key-resolved
|
|
224
|
+
// identity round-trips: a cleartext name never collides with a 64-char hex
|
|
225
|
+
// hash, so this is safe (docs/accesscontrol.md §6.5).
|
|
226
|
+
targetHashes.add(username);
|
|
227
|
+
const matchesTarget = (value) => typeof value === "string" && targetHashes.has(value);
|
|
115
228
|
const matchingDocs = [];
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
229
|
+
// Use the generator-based iteration API for cleaner code.
|
|
230
|
+
// No signature verification needed - DB already enforces admin-only.
|
|
231
|
+
// Revocation no longer uses a separate document: it removes keys from the
|
|
232
|
+
// grant document in place (docs/accesscontrol.md §6.5). A fully-revoked user
|
|
233
|
+
// therefore still has a grant document here, but with empty key arrays.
|
|
234
|
+
// Callers that need "active access" should inspect the key arrays (see
|
|
235
|
+
// {@link isUserRevoked} / {@link getUserPublicKeys}).
|
|
119
236
|
for await (const { doc } of directoryDB.iterateChangesSince(null)) {
|
|
120
237
|
const data = doc.getData();
|
|
121
|
-
// Check if this is a grant access document we're looking for (by username_hash)
|
|
122
238
|
if (data.form === "useroperation" &&
|
|
123
239
|
data.type === "grantaccess" &&
|
|
124
|
-
data.username_hash
|
|
125
|
-
this.logger.debug(`Found grant access document for username
|
|
240
|
+
matchesTarget(data.username_hash)) {
|
|
241
|
+
this.logger.debug(`Found grant access document for username: ${username}`);
|
|
126
242
|
matchingDocs.push(doc);
|
|
127
243
|
}
|
|
128
|
-
// Check if this is a revoke access document for the same username (by username_hash)
|
|
129
|
-
if (data.form === "useroperation" &&
|
|
130
|
-
data.type === "revokeaccess" &&
|
|
131
|
-
data.username_hash === targetHash &&
|
|
132
|
-
data.revokeDocId &&
|
|
133
|
-
typeof data.revokeDocId === "string") {
|
|
134
|
-
this.logger.debug(`Found revoke access document for username hash: ${targetHash}, revoking doc ID: ${data.revokeDocId}`);
|
|
135
|
-
revokedDocIds.add(data.revokeDocId);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
// Filter out revoked documents
|
|
139
|
-
const activeDocs = matchingDocs.filter(doc => !revokedDocIds.has(doc.getId()));
|
|
140
|
-
if (activeDocs.length === 0) {
|
|
141
|
-
this.logger.debug(`No active grant access documents found for username: ${username} (found ${matchingDocs.length} total, ${revokedDocIds.size} revoked)`);
|
|
142
|
-
}
|
|
143
|
-
else {
|
|
144
|
-
this.logger.debug(`Found ${activeDocs.length} active grant access document(s) for username: ${username} (${matchingDocs.length} total, ${revokedDocIds.size} revoked)`);
|
|
145
244
|
}
|
|
146
|
-
|
|
245
|
+
this.logger.debug(`Found ${matchingDocs.length} grant access document(s) for username: ${username}`);
|
|
246
|
+
return matchingDocs;
|
|
147
247
|
}
|
|
148
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Revoke a user's access by removing their keys from the grant document
|
|
250
|
+
* (docs/accesscontrol.md §6.5). This replaces the legacy `revokeaccess`
|
|
251
|
+
* document model entirely: revocation is now an in-place edit of the grant
|
|
252
|
+
* document's `userSigningPublicKeys` / `userEncryptionPublicKeys` arrays.
|
|
253
|
+
*
|
|
254
|
+
* By default (no `options.signingKeys`) the user is fully revoked: every
|
|
255
|
+
* signing AND encryption key is removed, so the grant document remains but
|
|
256
|
+
* carries no keys. To revoke a single device/key, pass the specific
|
|
257
|
+
* `signingKeys` (and optionally matching `encryptionKeys`) to remove.
|
|
258
|
+
*
|
|
259
|
+
* When `options.requestDataWipe` is true, the removed signing keys are also
|
|
260
|
+
* flagged for a remote device wipe (§6.5) before removal, so the targeted
|
|
261
|
+
* devices delete their local tenant on next sync. The wipe directive is
|
|
262
|
+
* stored self-contained on the grant document and survives key removal.
|
|
263
|
+
*
|
|
264
|
+
* BREAKING CHANGE: the previous signature took a positional `requestDataWipe`
|
|
265
|
+
* boolean; it now takes an options object so specific keys can be targeted.
|
|
266
|
+
*
|
|
267
|
+
* @param username The user to revoke (format: "CN=<username>/O=<tenantId>").
|
|
268
|
+
* @param options.signingKeys Specific signing keys to remove; omit/empty to
|
|
269
|
+
* remove all of the user's keys (full revocation).
|
|
270
|
+
* @param options.encryptionKeys Specific encryption keys to remove. Ignored
|
|
271
|
+
* for a full revocation (all encryption keys are removed in that case).
|
|
272
|
+
* @param options.requestDataWipe Also request a remote wipe of the removed
|
|
273
|
+
* signing keys' devices.
|
|
274
|
+
*/
|
|
275
|
+
async revokeUser(username, options, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
149
276
|
this.logger.info(`Revoking user: ${username}`);
|
|
150
|
-
// Find all grant access documents for this user
|
|
151
277
|
const grantAccessDocs = await this.findGrantAccessDocuments(username);
|
|
152
|
-
// If no grant access documents found, exit early
|
|
153
278
|
if (grantAccessDocs.length === 0) {
|
|
154
279
|
this.logger.debug(`No grant access documents found for ${username}, exiting revocation`);
|
|
155
280
|
return;
|
|
156
281
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
await directoryDB.changeDoc(newDoc, async (doc) => {
|
|
176
|
-
const data = doc.getData();
|
|
177
|
-
data.form = "useroperation";
|
|
178
|
-
data.type = "revokeaccess";
|
|
179
|
-
data.username_hash = usernameHash;
|
|
180
|
-
data.user_details_encrypted = userDetailsEncrypted;
|
|
181
|
-
data.revokeDocId = revokeDocId;
|
|
182
|
-
data.requestDataWipe = requestDataWipe;
|
|
183
|
-
}, {
|
|
184
|
-
signingKeyPair: adminSigningKeyPair,
|
|
185
|
-
signingKeyPassword: administrationPrivateKeyPassword,
|
|
186
|
-
});
|
|
187
|
-
this.logger.debug(`Created revocation document for grant access doc: ${revokeDocId}`);
|
|
282
|
+
const fullRevoke = !options.signingKeys || options.signingKeys.length === 0;
|
|
283
|
+
// Determine which signing/encryption keys to strip from the grant. A full
|
|
284
|
+
// revocation removes every key currently present across the user's grant
|
|
285
|
+
// documents; a targeted revocation removes only the supplied keys.
|
|
286
|
+
let signingToRemove;
|
|
287
|
+
let encryptionToRemove;
|
|
288
|
+
if (fullRevoke) {
|
|
289
|
+
const allSigning = new Set();
|
|
290
|
+
const allEncryption = new Set();
|
|
291
|
+
for (const grant of grantAccessDocs) {
|
|
292
|
+
const data = grant.getData();
|
|
293
|
+
for (const key of (0, grantKeys_1.extractSigningPublicKeys)(data))
|
|
294
|
+
allSigning.add(key);
|
|
295
|
+
for (const key of (0, grantKeys_1.extractEncryptionPublicKeys)(data))
|
|
296
|
+
allEncryption.add(key);
|
|
297
|
+
}
|
|
298
|
+
signingToRemove = Array.from(allSigning);
|
|
299
|
+
encryptionToRemove = Array.from(allEncryption);
|
|
188
300
|
}
|
|
189
|
-
|
|
190
|
-
|
|
301
|
+
else {
|
|
302
|
+
signingToRemove = options.signingKeys ?? [];
|
|
303
|
+
encryptionToRemove = options.encryptionKeys ?? [];
|
|
304
|
+
}
|
|
305
|
+
// Flag the removed devices for remote wipe BEFORE removing the keys, so the
|
|
306
|
+
// self-contained wipe directive is recorded on the grant document (§6.5).
|
|
307
|
+
if (options.requestDataWipe && signingToRemove.length > 0) {
|
|
308
|
+
await this.requestDeviceWipe(username, signingToRemove, administrationPrivateKey, administrationPrivateKeyPassword);
|
|
309
|
+
}
|
|
310
|
+
await this.removeUserKeys(username, signingToRemove, encryptionToRemove, administrationPrivateKey, administrationPrivateKeyPassword);
|
|
311
|
+
// removeUserKeys already refreshes the unified cache; refresh once more to
|
|
312
|
+
// be explicit so subsequent validatePublicSigningKey calls observe the
|
|
313
|
+
// revocation without waiting for the next sync interval.
|
|
191
314
|
await this.updateUnifiedCache();
|
|
192
|
-
this.logger.info(
|
|
315
|
+
this.logger.info(fullRevoke
|
|
316
|
+
? `Revoked user: ${username} (removed all keys from ${grantAccessDocs.length} grant document(s))`
|
|
317
|
+
: `Revoked ${signingToRemove.length} signing key(s) for user: ${username}`);
|
|
193
318
|
}
|
|
194
|
-
async validatePublicSigningKey(publicKey) {
|
|
319
|
+
async validatePublicSigningKey(publicKey, opts) {
|
|
195
320
|
this.logger.debug(`Validating public signing key`);
|
|
196
321
|
// Cast to BaseMindooTenant to access protected methods
|
|
197
322
|
const baseTenant = this.tenant;
|
|
@@ -212,168 +337,2132 @@ class BaseMindooTenantDirectory {
|
|
|
212
337
|
return additionalResult;
|
|
213
338
|
}
|
|
214
339
|
}
|
|
215
|
-
// Refresh directory state at most once per interval
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
340
|
+
// Refresh directory state at most once per interval — unless the caller
|
|
341
|
+
// forces a refresh (audit #4): the server push path must observe a freshly
|
|
342
|
+
// pushed revocation immediately rather than lagging by up to
|
|
343
|
+
// DIRECTORY_SYNC_INTERVAL_MS, which would let a just-revoked key keep
|
|
344
|
+
// pushing entries.
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
let didSync = false;
|
|
347
|
+
if (opts?.forceRefresh ||
|
|
348
|
+
now - this.lastDirectorySyncTimestamp >= DIRECTORY_SYNC_INTERVAL_MS) {
|
|
349
|
+
const directoryDB = await this.getDirectoryDB();
|
|
350
|
+
await directoryDB.syncStoreChanges();
|
|
351
|
+
await this.updateUnifiedCache();
|
|
352
|
+
this.lastDirectorySyncTimestamp = now;
|
|
353
|
+
didSync = true;
|
|
354
|
+
}
|
|
355
|
+
let cachedResult = this.trustedKeysCache.get(publicKey);
|
|
356
|
+
if (cachedResult === undefined && !didSync) {
|
|
357
|
+
// Cache miss and not synced yet - sync once and re-check immediately.
|
|
358
|
+
const directoryDB = await this.getDirectoryDB();
|
|
359
|
+
await directoryDB.syncStoreChanges();
|
|
360
|
+
await this.updateUnifiedCache();
|
|
361
|
+
this.lastDirectorySyncTimestamp = Date.now();
|
|
362
|
+
cachedResult = this.trustedKeysCache.get(publicKey);
|
|
363
|
+
}
|
|
364
|
+
if (cachedResult !== undefined) {
|
|
365
|
+
this.logger.debug(`Public key validation result (from cache): ${cachedResult}`);
|
|
366
|
+
return cachedResult;
|
|
367
|
+
}
|
|
368
|
+
// Key not found in cache means it was never granted access
|
|
369
|
+
this.logger.debug(`Public key not found in cache, returning false`);
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Update all caches (trusted keys, groups, settings) by processing new changes since the last cursor.
|
|
374
|
+
* This unified method processes all document types in a single loop for efficiency.
|
|
375
|
+
* No signature verification is needed since the DB already enforces admin-only access.
|
|
376
|
+
*/
|
|
377
|
+
async updateUnifiedCache() {
|
|
378
|
+
const directoryDB = await this.getDirectoryDB();
|
|
379
|
+
// Determine starting cursor (null = process all, otherwise incremental)
|
|
380
|
+
const startCursor = this.unifiedCacheLastCursor;
|
|
381
|
+
// If processing from the beginning, clear all caches first
|
|
382
|
+
if (startCursor === null) {
|
|
383
|
+
this.trustedKeysCache.clear();
|
|
384
|
+
this.grantDocIdToSigningKeys.clear();
|
|
385
|
+
this.userLookupCache.clear();
|
|
386
|
+
this.wipeKeyToGrantDocId.clear();
|
|
387
|
+
this.tenantSettingsCache = null;
|
|
388
|
+
this.dbSettingsCache.clear();
|
|
389
|
+
this.groupsCache.clear();
|
|
390
|
+
this.keyDistributionCache.clear();
|
|
391
|
+
}
|
|
392
|
+
// Track group documents by name for merging (handles offline sync scenarios)
|
|
393
|
+
const groupDocsByName = new Map();
|
|
394
|
+
// Process changes (documents are returned in order by lastModified, oldest to newest)
|
|
395
|
+
for await (const { doc, cursor } of directoryDB.iterateChangesSince(startCursor)) {
|
|
396
|
+
const data = doc.getData();
|
|
397
|
+
// Process user operation documents (grant/revoke access)
|
|
398
|
+
if (data.form === "useroperation") {
|
|
399
|
+
// Check if this is a grant access document. A grant may carry MULTIPLE
|
|
400
|
+
// signing keys (key rollover / multiple devices, §6.5); honor all of
|
|
401
|
+
// them, with a legacy scalar fallback handled by extractSigningPublicKeys.
|
|
402
|
+
const grantedSigningKeys = data.type === "grantaccess" ? (0, grantKeys_1.extractSigningPublicKeys)(data) : [];
|
|
403
|
+
// Index any remote-wipe directive on this grant (§6.5) regardless of how
|
|
404
|
+
// many signing keys remain: the wipe values are self-contained and must
|
|
405
|
+
// survive full key-array revocation so a revoked device can still be told
|
|
406
|
+
// to wipe. Maps each wipe-targeted signing key to this grant doc's id.
|
|
407
|
+
if (data.type === "grantaccess") {
|
|
408
|
+
for (const wipeKey of (0, grantKeys_1.extractWipeRequestedSigningKeys)(data)) {
|
|
409
|
+
this.wipeKeyToGrantDocId.set(wipeKey, doc.getId());
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (data.type === "grantaccess") {
|
|
413
|
+
// Record this grant document's CURRENT signing keys. Because grant
|
|
414
|
+
// documents are Automerge-merged in place, removing keys (the only
|
|
415
|
+
// revocation mechanism, §6.5) is reflected by a smaller array here.
|
|
416
|
+
// The authoritative trustedKeysCache is rebuilt from the union of
|
|
417
|
+
// all grant documents once the change loop completes, so a key
|
|
418
|
+
// dropped from this document is no longer trusted unless another
|
|
419
|
+
// grant still lists it.
|
|
420
|
+
this.grantDocIdToSigningKeys.set(doc.getId(), grantedSigningKeys);
|
|
421
|
+
if (grantedSigningKeys.length > 0) {
|
|
422
|
+
const userLookup = await this.buildUserLookup(data);
|
|
423
|
+
if (userLookup) {
|
|
424
|
+
// Keep identity labels available even after the keys are later
|
|
425
|
+
// revoked, so historical UIs can still resolve old authors.
|
|
426
|
+
for (const userPublicKey of grantedSigningKeys) {
|
|
427
|
+
this.userLookupCache.set(userPublicKey, userLookup);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Process group documents
|
|
434
|
+
if (data.form === "group" &&
|
|
435
|
+
data.type === "group" &&
|
|
436
|
+
data.groupName &&
|
|
437
|
+
typeof data.groupName === "string") {
|
|
438
|
+
const normalizedGroupName = this.normalizeGroupName(data.groupName);
|
|
439
|
+
if (!groupDocsByName.has(normalizedGroupName)) {
|
|
440
|
+
groupDocsByName.set(normalizedGroupName, []);
|
|
441
|
+
}
|
|
442
|
+
groupDocsByName.get(normalizedGroupName).push(doc);
|
|
443
|
+
}
|
|
444
|
+
// Process tenant settings (no signature verification needed - Automerge handles merging)
|
|
445
|
+
if (data.form === "tenantsettings") {
|
|
446
|
+
// Just overwrite - last one seen will be the latest
|
|
447
|
+
this.tenantSettingsCache = doc;
|
|
448
|
+
}
|
|
449
|
+
// Process DB settings (no signature verification needed - Automerge handles merging)
|
|
450
|
+
if (data.form === "dbsettings" && data.dbid && typeof data.dbid === "string") {
|
|
451
|
+
const dbId = data.dbid;
|
|
452
|
+
// Just overwrite - last one seen will be the latest
|
|
453
|
+
this.dbSettingsCache.set(dbId, doc);
|
|
454
|
+
}
|
|
455
|
+
// Process key-distribution documents (acl_keydistribution_<keyId>); a
|
|
456
|
+
// singleton per keyId. Cache head state here so the revoked-key resolver
|
|
457
|
+
// and the KeyBag reconcile read it without re-scanning directory docs
|
|
458
|
+
// (docs/accesscontrol.md §13). A deleted distribution drops its entry.
|
|
459
|
+
if (data.type === types_3.KEY_DISTRIBUTION_TYPE && typeof data.keyId === "string") {
|
|
460
|
+
const keyId = data.keyId;
|
|
461
|
+
if (doc.isDeleted()) {
|
|
462
|
+
this.keyDistributionCache.delete(keyId);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
this.keyDistributionCache.set(keyId, {
|
|
466
|
+
keyVersions: Array.isArray(data.keyVersions)
|
|
467
|
+
? data.keyVersions
|
|
468
|
+
.filter((v) => !!v && typeof v.fingerprint === "string")
|
|
469
|
+
.map((v) => ({ createdAt: Number(v.createdAt) || 0, fingerprint: v.fingerprint }))
|
|
470
|
+
: [],
|
|
471
|
+
pullfrom_users_hashes: Array.isArray(data.pullfrom_users_hashes)
|
|
472
|
+
? data.pullfrom_users_hashes.filter((h) => typeof h === "string")
|
|
473
|
+
: [],
|
|
474
|
+
pushto_users_hashes: Array.isArray(data.pushto_users_hashes)
|
|
475
|
+
? data.pushto_users_hashes.filter((h) => typeof h === "string")
|
|
476
|
+
: [],
|
|
477
|
+
pushto_users_keys: data.pushto_users_keys && typeof data.pushto_users_keys === "object"
|
|
478
|
+
? data.pushto_users_keys
|
|
479
|
+
: {},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Process app-distribution documents (acl_appdistribution_<appId>); a
|
|
484
|
+
// singleton per appId. Cache the recipient hash lists so the per-user
|
|
485
|
+
// reconcile reads membership without re-scanning directory docs
|
|
486
|
+
// (docs/accesscontrol.md §13). A deleted distribution drops its entry.
|
|
487
|
+
if (data.type === types_3.APP_DISTRIBUTION_TYPE && typeof data.appId === "string") {
|
|
488
|
+
const appId = data.appId;
|
|
489
|
+
if (doc.isDeleted()) {
|
|
490
|
+
this.appDistributionCache.delete(appId);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
|
|
494
|
+
this.appDistributionCache.set(appId, {
|
|
495
|
+
pushto_users_hashes: stringArray(data.pushto_users_hashes),
|
|
496
|
+
pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
|
|
497
|
+
pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
|
|
498
|
+
pullfrom_groups_hashes: stringArray(data.pullfrom_groups_hashes),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// Process sync-setup-policy documents (acl_syncsetuppolicy_<policyId>); one
|
|
503
|
+
// doc per policy. Cache the recipient hash lists + enforcement mode so the
|
|
504
|
+
// per-user reconcile reads membership without re-scanning directory docs.
|
|
505
|
+
// A deleted policy drops its entry.
|
|
506
|
+
if (data.type === types_3.SYNC_SETUP_POLICY_TYPE && typeof data.policyId === "string") {
|
|
507
|
+
const policyId = data.policyId;
|
|
508
|
+
if (doc.isDeleted()) {
|
|
509
|
+
this.syncSetupPolicyCache.delete(policyId);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
|
|
513
|
+
this.syncSetupPolicyCache.set(policyId, {
|
|
514
|
+
mode: data.mode === "permanent" ? "permanent" : "initial",
|
|
515
|
+
pushto_users_hashes: stringArray(data.pushto_users_hashes),
|
|
516
|
+
pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
|
|
517
|
+
pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
|
|
518
|
+
pullfrom_groups_hashes: stringArray(data.pullfrom_groups_hashes),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// Update cursor after each document
|
|
523
|
+
this.unifiedCacheLastCursor = cursor;
|
|
524
|
+
}
|
|
525
|
+
// Rebuild the trusted signing-key set as the union of every grant
|
|
526
|
+
// document's current keys (docs/accesscontrol.md §6.5). Revocation removes
|
|
527
|
+
// keys from a grant, so a key absent from this union is no longer trusted;
|
|
528
|
+
// validatePublicSigningKey treats "not present" as untrusted. This is
|
|
529
|
+
// recomputed on every pass (full or incremental) because the per-grant map
|
|
530
|
+
// is the source of truth and persists across incremental updates.
|
|
531
|
+
this.trustedKeysCache.clear();
|
|
532
|
+
for (const signingKeys of this.grantDocIdToSigningKeys.values()) {
|
|
533
|
+
for (const key of signingKeys) {
|
|
534
|
+
this.trustedKeysCache.set(key, true);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Merge group documents with the same name
|
|
538
|
+
for (const [normalizedGroupName, docs] of groupDocsByName.entries()) {
|
|
539
|
+
// Collect all member hashes and encrypted values from all documents with this group name
|
|
540
|
+
const allMembersHashes = new Set();
|
|
541
|
+
const allMembersEncrypted = new Set();
|
|
542
|
+
let latestDoc = null;
|
|
543
|
+
let isDeleted = false;
|
|
544
|
+
for (const doc of docs) {
|
|
545
|
+
const data = doc.getData();
|
|
546
|
+
// Track the latest document (by iteration order, which is by lastModified)
|
|
547
|
+
latestDoc = doc;
|
|
548
|
+
// Check if document is deleted via lifecycle metadata.
|
|
549
|
+
if (doc.isDeleted()) {
|
|
550
|
+
isDeleted = true;
|
|
551
|
+
}
|
|
552
|
+
// Use members_hashes for membership lookups.
|
|
553
|
+
if (data.members_hashes && Array.isArray(data.members_hashes)) {
|
|
554
|
+
for (const memberHash of data.members_hashes) {
|
|
555
|
+
if (typeof memberHash === "string") {
|
|
556
|
+
allMembersHashes.add(memberHash);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (data.members_encrypted && Array.isArray(data.members_encrypted)) {
|
|
561
|
+
for (const encryptedMember of data.members_encrypted) {
|
|
562
|
+
if (typeof encryptedMember === "string") {
|
|
563
|
+
allMembersEncrypted.add(encryptedMember);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// If latest version is deleted, remove from cache
|
|
569
|
+
if (isDeleted) {
|
|
570
|
+
this.groupsCache.delete(normalizedGroupName);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
// Store merged group data in cache (without mutating the MindooDoc)
|
|
574
|
+
if (latestDoc) {
|
|
575
|
+
this.groupsCache.set(normalizedGroupName, {
|
|
576
|
+
docId: latestDoc.getId(),
|
|
577
|
+
members_hashes: Array.from(allMembersHashes),
|
|
578
|
+
members_encrypted: Array.from(allMembersEncrypted),
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The head time-travel directory-state node ("now"), after ensuring the chain
|
|
585
|
+
* is current. Powers access-control evaluation (§7) and audit queries.
|
|
586
|
+
*/
|
|
587
|
+
async getDirectoryStateHead() {
|
|
588
|
+
const index = await this.ensureTimeTravelCurrent();
|
|
589
|
+
return index.getHead();
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* The time-travel directory-state node covering trusted time `T` (§8) — used
|
|
593
|
+
* by the evaluation algorithm and `wasAllowedAt` to judge an entry against the
|
|
594
|
+
* directory as it was at the entry's trusted time.
|
|
595
|
+
*/
|
|
596
|
+
async getDirectoryStateAt(T) {
|
|
597
|
+
const index = await this.ensureTimeTravelCurrent();
|
|
598
|
+
return index.getStateAt(T);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Lazily create the time-travel index (restoring its persisted delta log and
|
|
602
|
+
* registering it with the tenant CacheManager on first use), then bring the
|
|
603
|
+
* directory-state chain up to date from the revision-grain changefeed
|
|
604
|
+
* (docs/accesscontrol.md §8).
|
|
605
|
+
*
|
|
606
|
+
* The (potentially expensive) feed rebuild is skipped when the directory's
|
|
607
|
+
* changefeed cursor has not advanced since the previous build and no
|
|
608
|
+
* un-witnessed revisions are pending a possible trusted-time re-stamp.
|
|
609
|
+
*/
|
|
610
|
+
async ensureTimeTravelCurrent() {
|
|
611
|
+
const directoryDB = await this.getDirectoryDB();
|
|
612
|
+
// Projection used to (re)build the chain from stored revisions in
|
|
613
|
+
// trusted-time order. Captures this directory's group-name normalization.
|
|
614
|
+
const project = (builder, revision) => (0, directoryProjection_1.projectDirectoryRevision)(builder, {
|
|
615
|
+
docId: revision.docId,
|
|
616
|
+
data: revision.data,
|
|
617
|
+
deleted: revision.deleted,
|
|
618
|
+
trustedTime: revision.trustedTime,
|
|
619
|
+
normalizeGroupName: (name) => this.normalizeGroupName(name),
|
|
620
|
+
});
|
|
621
|
+
if (!this.timeTravel) {
|
|
622
|
+
const index = new DirectoryTimeTravelIndex_1.DirectoryTimeTravelIndex(`${this.tenant.getId()}/directory`);
|
|
623
|
+
const cacheManager = this.tenant.getCacheManager();
|
|
624
|
+
if (cacheManager) {
|
|
625
|
+
// Warm start: restore the persisted revisions (no decryption / Automerge)
|
|
626
|
+
// and replay them into the chain, then extend via the changefeed below.
|
|
627
|
+
await index.restoreFromCache(cacheManager.getStore());
|
|
628
|
+
index.rebuild(project);
|
|
629
|
+
cacheManager.register(index);
|
|
630
|
+
}
|
|
631
|
+
// Seed the in-memory gate cursor from the restored chain so a cold start
|
|
632
|
+
// can short-circuit when the directory has not advanced since the chain
|
|
633
|
+
// was flushed (otherwise the first call after every restart redundantly
|
|
634
|
+
// rebuilds the feed). `null` keeps the legacy behavior (force a build).
|
|
635
|
+
this.lastTimeTravelChangeSeq = index.lastChangeSeq;
|
|
636
|
+
this.timeTravel = index;
|
|
637
|
+
}
|
|
638
|
+
const index = this.timeTravel;
|
|
639
|
+
// Gate the (potentially expensive) feed rebuild on the in-memory changefeed
|
|
640
|
+
// cursor. We deliberately do NOT call syncStoreChanges() here: like the
|
|
641
|
+
// legacy updateUnifiedCache read path, this method must not trigger network
|
|
642
|
+
// pulls or re-entrant directory syncing when invoked from server-side entry
|
|
643
|
+
// validation. The revision feed reads the directory store directly, so it
|
|
644
|
+
// already observes every persisted entry; the cursor is only used to decide
|
|
645
|
+
// when a rebuild is worthwhile.
|
|
646
|
+
const latest = directoryDB.getLatestChangeCursor?.() ?? null;
|
|
647
|
+
const latestSeq = latest?.changeSeq ?? null;
|
|
648
|
+
// Fast path: the directory has not advanced since the last build. The
|
|
649
|
+
// un-witnessed head overlay is refreshed when `changeSeq` next advances
|
|
650
|
+
// (notably when a re-stamped entry is re-discovered with a `receivedAt`), so
|
|
651
|
+
// we do not need to re-run the feed on every call just to refresh the
|
|
652
|
+
// provisional `now` of un-witnessed entries.
|
|
653
|
+
if (this.lastTimeTravelChangeSeq !== null && latestSeq === this.lastTimeTravelChangeSeq) {
|
|
654
|
+
return index;
|
|
655
|
+
}
|
|
656
|
+
// Incrementally advance the feed from the persisted cursor; the feed parks
|
|
657
|
+
// the cursor before the earliest un-witnessed entry so those revisions are
|
|
658
|
+
// re-discovered (and re-stamped/superseded as needed) on this resume.
|
|
659
|
+
let changed = false;
|
|
660
|
+
for await (const rev of directoryDB.iterateChangeRevisionsSince(index.cursor)) {
|
|
661
|
+
const revChanged = index.upsertRevision({
|
|
662
|
+
entryId: rev.entryId,
|
|
663
|
+
docId: rev.docId,
|
|
664
|
+
data: rev.doc.getData(),
|
|
665
|
+
deleted: rev.doc.isDeleted(),
|
|
666
|
+
trustedTime: rev.trustedTime,
|
|
667
|
+
witnessed: rev.witnessed,
|
|
668
|
+
}, rev.cursor);
|
|
669
|
+
changed = changed || revChanged;
|
|
670
|
+
}
|
|
671
|
+
// Replay all revisions into the chain in trusted-time order. This absorbs
|
|
672
|
+
// out-of-order arrivals and supersessions (a re-emitted revision replaced its
|
|
673
|
+
// prior record above) without any special-casing in the chain builder.
|
|
674
|
+
if (changed) {
|
|
675
|
+
index.rebuild(project);
|
|
676
|
+
}
|
|
677
|
+
this.lastTimeTravelChangeSeq = latestSeq;
|
|
678
|
+
// Persist the observed changeSeq with the chain so the gate survives restart.
|
|
679
|
+
index.recordChangeSeq(latestSeq);
|
|
680
|
+
if (index.hasDirtyState()) {
|
|
681
|
+
this.tenant.getCacheManager()?.markDirty();
|
|
682
|
+
}
|
|
683
|
+
return index;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* The set of witness public keys trusted at trusted time `T`
|
|
687
|
+
* (docs/accesscontrol.md §6.4), used by the client to validate witness
|
|
688
|
+
* receipts on incoming entries during materialization (§5.4).
|
|
689
|
+
*/
|
|
690
|
+
async getTrustedWitnessKeysAt(T) {
|
|
691
|
+
const node = await this.getDirectoryStateAt(T);
|
|
692
|
+
return new Set(node.trustedWitnessKeys.keys());
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Resolve the acting user's identity set for access-control evaluation
|
|
696
|
+
* (docs/accesscontrol.md §7 step 3): username hash (v1 + v2), every group hash
|
|
697
|
+
* (including nested groups), the user's resolved usernames/groups for
|
|
698
|
+
* placeholder expansion, and the applicable pseudo-tokens (`$everyone` always;
|
|
699
|
+
* `$admin`/`$author` when the caller indicates they apply).
|
|
700
|
+
*
|
|
701
|
+
* @param username The acting user's canonical username.
|
|
702
|
+
* @param opts.isAdmin Whether the acting signing key is the tenant admin key.
|
|
703
|
+
* @param opts.isAuthor Whether the acting user authored the target document
|
|
704
|
+
* (the caller resolves this by mapping the document's creator key and the
|
|
705
|
+
* change signer key to the same grant; see §6.3 `$author`).
|
|
706
|
+
*/
|
|
707
|
+
async buildIdentitySet(username, opts = {}) {
|
|
708
|
+
await this.updateUnifiedCache();
|
|
709
|
+
const usernames = await this.getUserNamesList(username);
|
|
710
|
+
const groups = await this.resolveGroupsForUser(username);
|
|
711
|
+
const hashes = new Set();
|
|
712
|
+
for (const h of await this.usernameHashCandidates(username)) {
|
|
713
|
+
hashes.add(h);
|
|
714
|
+
}
|
|
715
|
+
// Group membership: rules target group-name hashes, so the identity set
|
|
716
|
+
// must include the hash of every group the user belongs to.
|
|
717
|
+
for (const group of groups) {
|
|
718
|
+
for (const h of await this.usernameHashCandidates(group)) {
|
|
719
|
+
hashes.add(h);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
hashes.add(types_3.PSEUDO_TOKEN_EVERYONE);
|
|
723
|
+
if (opts.isAdmin)
|
|
724
|
+
hashes.add(types_3.PSEUDO_TOKEN_ADMIN);
|
|
725
|
+
if (opts.isAuthor)
|
|
726
|
+
hashes.add(types_3.PSEUDO_TOKEN_AUTHOR);
|
|
727
|
+
return { username, usernames, groups, hashes };
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Server-side Tier 1 evaluation for a pushed entry (docs/accesscontrol.md §7,
|
|
731
|
+
* §10). The server identifies the author by signing key (it cannot read
|
|
732
|
+
* encrypted content), resolves their identity set, and evaluates the identity
|
|
733
|
+
* tier of the policy at `trustedTime`. Tier 2 (content) rules are deferred to
|
|
734
|
+
* clients — `evaluateAccess` is called with `isServer: true`, so a gate that
|
|
735
|
+
* only a Tier 2 rule could decide is treated as allowed here.
|
|
736
|
+
*
|
|
737
|
+
* @param input.op The operation (entry type) being pushed.
|
|
738
|
+
* @param input.dbid The database the entry targets (witness binds this).
|
|
739
|
+
* @param input.signingKey The author's Ed25519 signing public key (PEM).
|
|
740
|
+
* @param input.trustedTime The trusted time to evaluate at (the witness's
|
|
741
|
+
* acceptance time for an inbound push).
|
|
742
|
+
* @param input.isAuthor Whether the author is the document's original creator
|
|
743
|
+
* (resolved by the caller for `$author`; see §6.3). For `doc_create` the
|
|
744
|
+
* author is always the creator.
|
|
745
|
+
*/
|
|
746
|
+
async evaluateAccessForSigningKey(input) {
|
|
747
|
+
const node = await this.getDirectoryStateAt(input.trustedTime);
|
|
748
|
+
const identity = await this.identitySetForSigningKey(input.signingKey, node, {
|
|
749
|
+
isAuthor: input.op === "doc_create" || !!input.isAuthor,
|
|
750
|
+
});
|
|
751
|
+
return (0, evaluate_1.evaluateAccess)({
|
|
752
|
+
op: input.op,
|
|
753
|
+
dbid: input.dbid,
|
|
754
|
+
identity,
|
|
755
|
+
node,
|
|
756
|
+
isServer: true,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Client-side (Tier 1 + Tier 2) evaluation for a single entry during
|
|
761
|
+
* materialization (docs/accesscontrol.md §7, §10). Unlike the server path,
|
|
762
|
+
* the client can read decrypted content, so it evaluates `withfields` clauses
|
|
763
|
+
* against the supplied `before`/`after` document states.
|
|
764
|
+
*/
|
|
765
|
+
async evaluateClientAccess(input) {
|
|
766
|
+
const node = await this.getDirectoryStateAt(input.trustedTime);
|
|
767
|
+
const identity = await this.identitySetForSigningKey(input.signingKey, node, {
|
|
768
|
+
isAuthor: input.op === "doc_create" || input.isAuthor,
|
|
769
|
+
});
|
|
770
|
+
return (0, evaluate_1.evaluateAccess)({
|
|
771
|
+
op: input.op,
|
|
772
|
+
dbid: input.dbid,
|
|
773
|
+
identity,
|
|
774
|
+
node,
|
|
775
|
+
beforeDoc: input.beforeDoc,
|
|
776
|
+
afterDoc: input.afterDoc,
|
|
777
|
+
isServer: false,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Whether the active policy has any Tier 2 (`withfields`) content rule for
|
|
782
|
+
* operation `op` in database `dbid` at the current head state. Lets the
|
|
783
|
+
* client write prechecks skip the (potentially costly) Automerge -> JS
|
|
784
|
+
* materialization of the before/after document when only Tier 1
|
|
785
|
+
* (identity/op) checks could apply (docs/accesscontrol.md §9).
|
|
786
|
+
*/
|
|
787
|
+
async hasWriteContentRules(op, dbid) {
|
|
788
|
+
const node = await this.getDirectoryStateHead();
|
|
789
|
+
const rules = node.rulesByType.get(op) ?? [];
|
|
790
|
+
return rules.some((r) => (r.dbid === dbid || r.dbid === "*") && (r.withfields?.length ?? 0) > 0);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Whether access control is active for this tenant right now: a default
|
|
794
|
+
* policy exists and the master kill-switch is not engaged
|
|
795
|
+
* (docs/accesscontrol.md §6.1, §7 step 0). When false, callers can skip the
|
|
796
|
+
* (more expensive) per-entry access evaluation entirely.
|
|
797
|
+
*/
|
|
798
|
+
async isAccessControlActive() {
|
|
799
|
+
const head = await this.getDirectoryStateHead();
|
|
800
|
+
if (head.defaultPolicy === null)
|
|
801
|
+
return false;
|
|
802
|
+
return head.defaultPolicy.disableAllAccessChecksAndPolicies !== true;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* The effective default `decryptionKeyId` for a `doc_create` in `dbid` at the
|
|
806
|
+
* current head state, or `undefined` when none is configured. Lets the client
|
|
807
|
+
* fill in the key when the caller omits one, instead of falling back to the
|
|
808
|
+
* hardcoded `"default"`. This is a create-time convenience, not a security
|
|
809
|
+
* control — which keys a user may create/read under is governed by key
|
|
810
|
+
* possession (the key distribution model, docs/accesscontrol.md §13).
|
|
811
|
+
*/
|
|
812
|
+
async getEffectiveDefaultCreateKeyId(dbid) {
|
|
813
|
+
const head = await this.getDirectoryStateHead();
|
|
814
|
+
if (head.defaultPolicy === null)
|
|
815
|
+
return undefined;
|
|
816
|
+
const eff = (0, types_3.effectivePolicy)(head.defaultPolicy, head.dbPolicies.get(dbid) ?? null);
|
|
817
|
+
if (eff.disableAllAccessChecksAndPolicies)
|
|
818
|
+
return undefined;
|
|
819
|
+
return eff.defaultCreateKeyId;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* The tenant-wide database-open policy at the current head state.
|
|
823
|
+
*
|
|
824
|
+
* This is a tenant-level control read only from the `acl_defaultpolicy`
|
|
825
|
+
* document (never layered per-db). When no default policy exists (access
|
|
826
|
+
* control off), or the field is omitted, the policy is `"open"`. In
|
|
827
|
+
* `"directory-restricted"` mode only `"directory"` (always implicitly
|
|
828
|
+
* allowed) and the returned `allowedDbIds` may be opened/synced.
|
|
829
|
+
*/
|
|
830
|
+
async getDatabaseCreationPolicy() {
|
|
831
|
+
const head = await this.getDirectoryStateHead();
|
|
832
|
+
const policy = head.defaultPolicy;
|
|
833
|
+
if (policy === null || policy.databaseCreationPolicy !== "directory-restricted") {
|
|
834
|
+
return { mode: "open", allowedDbIds: [] };
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
mode: "directory-restricted",
|
|
838
|
+
allowedDbIds: Array.isArray(policy.allowedDbIds) ? [...policy.allowedDbIds] : [],
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* The tenant-wide storage-format floor (`requireMetadataSignatureSince`): the
|
|
843
|
+
* trusted-time cutoff at/after which entries must carry the v2 metadata-binding
|
|
844
|
+
* signature, or `undefined` when no floor is configured (fully backward
|
|
845
|
+
* compatible). Tenant-level only, read from the `acl_defaultpolicy` head.
|
|
846
|
+
*
|
|
847
|
+
* Cached with a short TTL and guarded against re-entrancy: resolving the
|
|
848
|
+
* cutoff loads the directory head, and directory materialization itself runs
|
|
849
|
+
* signature verification — without the guard a verify→resolve→materialize→
|
|
850
|
+
* verify cycle could recurse. During such re-entry we return the last known
|
|
851
|
+
* value (the directory store is anyway exempt from the floor by its callers).
|
|
852
|
+
*/
|
|
853
|
+
async getRequireMetadataSignatureSince() {
|
|
854
|
+
const now = Date.now();
|
|
855
|
+
if (this.resolvingMetadataSignatureCutoff) {
|
|
856
|
+
return this.metadataSignatureCutoffCache;
|
|
857
|
+
}
|
|
858
|
+
if (this.metadataSignatureCutoffCachedAt !== 0 &&
|
|
859
|
+
now - this.metadataSignatureCutoffCachedAt < METADATA_SIGNATURE_CUTOFF_TTL_MS) {
|
|
860
|
+
return this.metadataSignatureCutoffCache;
|
|
861
|
+
}
|
|
862
|
+
this.resolvingMetadataSignatureCutoff = true;
|
|
863
|
+
try {
|
|
864
|
+
const head = await this.getDirectoryStateHead();
|
|
865
|
+
const since = head.defaultPolicy?.requireMetadataSignatureSince;
|
|
866
|
+
this.metadataSignatureCutoffCache =
|
|
867
|
+
typeof since === "number" && Number.isFinite(since) ? since : undefined;
|
|
868
|
+
this.metadataSignatureCutoffCachedAt = now;
|
|
869
|
+
}
|
|
870
|
+
finally {
|
|
871
|
+
this.resolvingMetadataSignatureCutoff = false;
|
|
872
|
+
}
|
|
873
|
+
return this.metadataSignatureCutoffCache;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Whether `dbid` may be opened/synced under the current database-open policy.
|
|
877
|
+
*
|
|
878
|
+
* Always true for `"directory"` and in `"open"` mode. In
|
|
879
|
+
* `"directory-restricted"` mode, true only when `dbid` is in `allowedDbIds`
|
|
880
|
+
* or the optional `signingKey` is the tenant administration key (admin
|
|
881
|
+
* bypass).
|
|
882
|
+
*/
|
|
883
|
+
async isDatabaseAllowed(dbid, opts) {
|
|
884
|
+
if (dbid === "directory")
|
|
885
|
+
return true;
|
|
886
|
+
const { mode, allowedDbIds } = await this.getDatabaseCreationPolicy();
|
|
887
|
+
if (mode === "open")
|
|
888
|
+
return true;
|
|
889
|
+
if (allowedDbIds.includes(dbid))
|
|
890
|
+
return true;
|
|
891
|
+
if (opts?.signingKey !== undefined) {
|
|
892
|
+
const adminKey = this.tenant.getAdministrationPublicKey();
|
|
893
|
+
if (opts.signingKey === adminKey)
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Server-oriented variant of {@link isDatabaseAllowed} that resolves the
|
|
900
|
+
* admin bypass from a request principal's signing key. Used by the sync
|
|
901
|
+
* server to gate all sync operations for a database id.
|
|
902
|
+
*/
|
|
903
|
+
async evaluateDbAccessForSigningKey(input) {
|
|
904
|
+
// Gate 1 — tenant-wide database-id allowlist (`databaseCreationPolicy`).
|
|
905
|
+
// Orthogonal to read access: a database must clear BOTH gates.
|
|
906
|
+
if (!(await this.isDatabaseAllowed(input.dbid, { signingKey: input.signingKey }))) {
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
// The directory database is never read-gated (it must always sync so the
|
|
910
|
+
// policy that defines the read gate can be read at all).
|
|
911
|
+
if (input.dbid === "directory")
|
|
912
|
+
return true;
|
|
913
|
+
// The tenant admin is exempt from the read gate.
|
|
914
|
+
const adminKey = this.tenant.getAdministrationPublicKey();
|
|
915
|
+
if (input.signingKey === adminKey)
|
|
916
|
+
return true;
|
|
917
|
+
// Gate 2 — per-user/group database read gate (`doc_read`, §6.6). Evaluated
|
|
918
|
+
// server-side (Tier 1) from the principal's signing key at acceptance time.
|
|
919
|
+
// A denied principal cannot pull or push the database, which also prevents
|
|
920
|
+
// creating data in it (read is required to create).
|
|
921
|
+
const decision = await this.evaluateAccessForSigningKey({
|
|
922
|
+
op: "doc_read",
|
|
923
|
+
dbid: input.dbid,
|
|
924
|
+
signingKey: input.signingKey,
|
|
925
|
+
trustedTime: Date.now(),
|
|
926
|
+
});
|
|
927
|
+
return decision.allowed;
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Whether the current user may open and sync `dbid` under the database read
|
|
931
|
+
* gate (`doc_read`, §6.6). The client-side counterpart of
|
|
932
|
+
* {@link evaluateDbAccessForSigningKey}'s read gate: it evaluates the
|
|
933
|
+
* `doc_read` policy for the current user against the directory head. The
|
|
934
|
+
* `"directory"` database is never gated and the tenant admin is always
|
|
935
|
+
* exempt. This does NOT apply the tenant-wide database-id allowlist
|
|
936
|
+
* ({@link isDatabaseAllowed}); callers enforce both gates.
|
|
937
|
+
*/
|
|
938
|
+
async canReadDatabase(dbid) {
|
|
939
|
+
if (dbid === "directory")
|
|
940
|
+
return true;
|
|
941
|
+
const currentUser = await this.tenant.getCurrentUserId();
|
|
942
|
+
const adminKey = this.tenant.getAdministrationPublicKey();
|
|
943
|
+
if (currentUser.userSigningPublicKey === adminKey)
|
|
944
|
+
return true;
|
|
945
|
+
const decision = await this.canDo("doc_read", dbid);
|
|
946
|
+
return decision.allowed;
|
|
947
|
+
}
|
|
948
|
+
// -------------------------------------------------------------------------
|
|
949
|
+
// Access-control authoring API (docs/accesscontrol.md §6). All writes are
|
|
950
|
+
// admin-signed and encrypted with `$publicinfos` so the sync server can read
|
|
951
|
+
// the Tier 1-relevant policy. Creating `acl_defaultpolicy` is what activates
|
|
952
|
+
// access control for the tenant (§6.1).
|
|
953
|
+
// -------------------------------------------------------------------------
|
|
954
|
+
/**
|
|
955
|
+
* Create or update the tenant-wide default policy (§6.1). The mere existence
|
|
956
|
+
* of this document activates access control. Only the fields supplied are
|
|
957
|
+
* written; omit a field to leave it unset (interpreted as its default).
|
|
958
|
+
*/
|
|
959
|
+
async setDefaultAccessPolicy(policy, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
960
|
+
(0, types_3.validateAccessPolicy)(policy);
|
|
961
|
+
await this.writePolicyDoc(types_3.ACL_DEFAULT_POLICY_DOC_ID, policy, administrationPrivateKey, administrationPrivateKeyPassword);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Create or update a per-database policy override (§6.2). Fields set here
|
|
965
|
+
* override the tenant default for `dbid`; unset fields inherit it.
|
|
966
|
+
*/
|
|
967
|
+
async setDatabaseAccessPolicy(dbid, policy, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
968
|
+
(0, types_3.validateAccessPolicy)(policy);
|
|
969
|
+
await this.writePolicyDoc((0, types_3.aclDbPolicyDocId)(dbid), policy, administrationPrivateKey, administrationPrivateKeyPassword);
|
|
970
|
+
}
|
|
971
|
+
/** Shared writer for default / per-db policy documents. */
|
|
972
|
+
async writePolicyDoc(docId, policy, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
973
|
+
const baseTenant = this.tenant;
|
|
974
|
+
const directoryDB = await this.getDirectoryDB();
|
|
975
|
+
const adminSigningKeyPair = {
|
|
976
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
977
|
+
privateKey: administrationPrivateKey,
|
|
978
|
+
};
|
|
979
|
+
const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
|
|
980
|
+
await directoryDB.changeDoc(doc, async (d) => {
|
|
981
|
+
const data = d.getData();
|
|
982
|
+
data.form = types_3.ACCESS_CONTROL_FORM;
|
|
983
|
+
data.type = "defaultpolicy";
|
|
984
|
+
for (const [key, value] of Object.entries(policy)) {
|
|
985
|
+
if (value !== undefined)
|
|
986
|
+
data[key] = value;
|
|
987
|
+
}
|
|
988
|
+
}, {
|
|
989
|
+
signingKeyPair: adminSigningKeyPair,
|
|
990
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
991
|
+
});
|
|
992
|
+
await this.updateUnifiedCache();
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Create or replace an access-control rule (§6.3). A rule with `withfields`
|
|
996
|
+
* is Tier 2 (client-enforced); otherwise it is Tier 1 (server-enforced). The
|
|
997
|
+
* spec is validated before writing.
|
|
998
|
+
*/
|
|
999
|
+
async createAccessRule(rule, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1000
|
+
// Resolve any username/group names into hashes and merge with explicit
|
|
1001
|
+
// hashes / pseudo-tokens (§6.3). Names are hashed the same way as
|
|
1002
|
+
// membership entries so they intersect the acting user's identity set.
|
|
1003
|
+
const resolvedHashes = await this.resolveRuleTargetHashes(rule);
|
|
1004
|
+
const ruleDoc = {
|
|
1005
|
+
form: types_3.ACCESS_CONTROL_FORM,
|
|
1006
|
+
type: rule.type,
|
|
1007
|
+
ruleId: rule.ruleId,
|
|
1008
|
+
description: rule.description,
|
|
1009
|
+
dbid: rule.dbid ?? "*",
|
|
1010
|
+
withfields: rule.withfields,
|
|
1011
|
+
users_hashes: Array.from(resolvedHashes),
|
|
1012
|
+
users_encrypted: await this.encryptRuleTargetsForTenant({
|
|
1013
|
+
usernames: rule.usernames ?? [],
|
|
1014
|
+
groups: rule.groups ?? [],
|
|
1015
|
+
}),
|
|
1016
|
+
action: rule.action ?? "allow",
|
|
1017
|
+
};
|
|
1018
|
+
// Fail fast on malformed clauses / operators (§6.3 closed set).
|
|
1019
|
+
(0, types_3.validateAclRule)(ruleDoc);
|
|
1020
|
+
const baseTenant = this.tenant;
|
|
1021
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1022
|
+
const adminSigningKeyPair = {
|
|
1023
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
1024
|
+
privateKey: administrationPrivateKey,
|
|
1025
|
+
};
|
|
1026
|
+
const doc = await this.getOrCreateAclDoc(directoryDB, (0, types_3.aclRuleDocId)(rule.ruleId), adminSigningKeyPair, administrationPrivateKeyPassword);
|
|
1027
|
+
await directoryDB.changeDoc(doc, async (d) => {
|
|
1028
|
+
const data = d.getData();
|
|
1029
|
+
data.form = types_3.ACCESS_CONTROL_FORM;
|
|
1030
|
+
data.type = ruleDoc.type;
|
|
1031
|
+
data.ruleId = ruleDoc.ruleId;
|
|
1032
|
+
data.dbid = ruleDoc.dbid;
|
|
1033
|
+
data.action = ruleDoc.action;
|
|
1034
|
+
data.users_hashes = ruleDoc.users_hashes;
|
|
1035
|
+
data.users_encrypted = ruleDoc.users_encrypted;
|
|
1036
|
+
if (ruleDoc.description !== undefined)
|
|
1037
|
+
data.description = ruleDoc.description;
|
|
1038
|
+
if (ruleDoc.withfields !== undefined)
|
|
1039
|
+
data.withfields = ruleDoc.withfields;
|
|
1040
|
+
}, {
|
|
1041
|
+
signingKeyPair: adminSigningKeyPair,
|
|
1042
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
1043
|
+
});
|
|
1044
|
+
await this.updateUnifiedCache();
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* List access-control rules currently in effect (§9), optionally filtered by
|
|
1048
|
+
* operation type and/or database. Rules with `dbid: "*"` match any database.
|
|
1049
|
+
*/
|
|
1050
|
+
async listRules(filter) {
|
|
1051
|
+
const head = await this.getDirectoryStateHead();
|
|
1052
|
+
const out = [];
|
|
1053
|
+
for (const [type, rules] of head.rulesByType) {
|
|
1054
|
+
if (filter?.type && filter.type !== type)
|
|
1055
|
+
continue;
|
|
1056
|
+
for (const rule of rules) {
|
|
1057
|
+
if (filter?.dbid && rule.dbid !== filter.dbid && rule.dbid !== "*")
|
|
1058
|
+
continue;
|
|
1059
|
+
const targets = await this.decryptRuleTargetsForTenant(rule.users_encrypted);
|
|
1060
|
+
out.push(targets ? { ...rule, targets } : rule);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return out;
|
|
1064
|
+
}
|
|
1065
|
+
/** Delete an access-control rule by id (§9): a soft-delete of its document. */
|
|
1066
|
+
async deleteRule(ruleId, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1067
|
+
const baseTenant = this.tenant;
|
|
1068
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1069
|
+
const adminSigningKeyPair = {
|
|
1070
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
1071
|
+
privateKey: administrationPrivateKey,
|
|
1072
|
+
};
|
|
1073
|
+
try {
|
|
1074
|
+
await directoryDB.deleteDocument((0, types_3.aclRuleDocId)(ruleId), {
|
|
1075
|
+
signingKeyPair: adminSigningKeyPair,
|
|
1076
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
catch (error) {
|
|
1080
|
+
this.logger.debug(`deleteRule: rule ${ruleId} not found or already deleted: ${error}`);
|
|
1081
|
+
}
|
|
1082
|
+
await this.updateUnifiedCache();
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Add (or refresh) a trusted witness (§6.4). The witness document is keyed by
|
|
1086
|
+
* a fingerprint of its public key so add/remove are symmetric.
|
|
1087
|
+
*/
|
|
1088
|
+
async addTrustedWitness(witness, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1089
|
+
const baseTenant = this.tenant;
|
|
1090
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1091
|
+
const adminSigningKeyPair = {
|
|
1092
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
1093
|
+
privateKey: administrationPrivateKey,
|
|
1094
|
+
};
|
|
1095
|
+
const fingerprint = await this.sha256Hex(witness.witnessPublicKey);
|
|
1096
|
+
const doc = await this.getOrCreateAclDoc(directoryDB, (0, types_2.aclTrustedWitnessDocId)(fingerprint), adminSigningKeyPair, administrationPrivateKeyPassword);
|
|
1097
|
+
await directoryDB.changeDoc(doc, async (d) => {
|
|
1098
|
+
const data = d.getData();
|
|
1099
|
+
data.form = types_3.ACCESS_CONTROL_FORM;
|
|
1100
|
+
data.type = "trustedwitness";
|
|
1101
|
+
data.witnessPublicKey = witness.witnessPublicKey;
|
|
1102
|
+
if (witness.serverUrl !== undefined)
|
|
1103
|
+
data.serverUrl = witness.serverUrl;
|
|
1104
|
+
}, { signingKeyPair: adminSigningKeyPair, signingKeyPassword: administrationPrivateKeyPassword });
|
|
1105
|
+
await this.updateUnifiedCache();
|
|
1106
|
+
}
|
|
1107
|
+
/** Remove a trusted witness by its public key (§6.4): soft-deletes its document. */
|
|
1108
|
+
async removeTrustedWitness(witnessPublicKey, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1109
|
+
const baseTenant = this.tenant;
|
|
1110
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1111
|
+
const adminSigningKeyPair = {
|
|
1112
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
1113
|
+
privateKey: administrationPrivateKey,
|
|
1114
|
+
};
|
|
1115
|
+
const fingerprint = await this.sha256Hex(witnessPublicKey);
|
|
1116
|
+
try {
|
|
1117
|
+
await directoryDB.deleteDocument((0, types_2.aclTrustedWitnessDocId)(fingerprint), {
|
|
1118
|
+
signingKeyPair: adminSigningKeyPair,
|
|
1119
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
this.logger.debug(`removeTrustedWitness: witness not found: ${error}`);
|
|
1124
|
+
}
|
|
1125
|
+
await this.updateUnifiedCache();
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Add device key pairs to a user's grant (§6.5 key rollover / new device).
|
|
1129
|
+
* Pairs are merged by signing key (a pair with an already-present signing key
|
|
1130
|
+
* updates its encryption key/label). See {@link mergeKeyPairs}.
|
|
1131
|
+
*/
|
|
1132
|
+
async addUserKeys(username, keyPairs, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1133
|
+
const additions = keyPairs.map((pair) => {
|
|
1134
|
+
const trimmedLabel = typeof pair.label === "string" ? pair.label.trim() : "";
|
|
1135
|
+
return trimmedLabel.length > 0
|
|
1136
|
+
? { ...pair, label: trimmedLabel }
|
|
1137
|
+
: { signingPublicKey: pair.signingPublicKey, encryptionPublicKey: pair.encryptionPublicKey };
|
|
1138
|
+
});
|
|
1139
|
+
await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
|
|
1140
|
+
(0, grantKeys_1.applyKeyPairFields)(data, (0, grantKeys_1.mergeKeyPairs)((0, grantKeys_1.extractKeyPairs)(data), additions));
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Revoke keys from a user's grant (§6.5 revoke a device/key). A device is
|
|
1145
|
+
* identified by its signing key; revoking a signing key marks its entire
|
|
1146
|
+
* paired entry revoked. `encryptionKeys` additionally targets any pair whose
|
|
1147
|
+
* encryption key matches (kept for callers that target encryption keys
|
|
1148
|
+
* directly).
|
|
1149
|
+
*
|
|
1150
|
+
* Revoked pairs are RETAINED on the grant document with `revoked: true` and a
|
|
1151
|
+
* `revokedAt` timestamp (instead of being deleted), so admin UIs can list and
|
|
1152
|
+
* optionally restore revoked devices. They are excluded from the active key
|
|
1153
|
+
* arrays (via {@link applyKeyPairFields}), so the server/auth treat them as
|
|
1154
|
+
* having no access — identical observable behavior to the previous
|
|
1155
|
+
* delete-based revocation.
|
|
1156
|
+
*/
|
|
1157
|
+
async removeUserKeys(username, signingKeys, encryptionKeys, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1158
|
+
const signingToRemove = new Set(signingKeys);
|
|
1159
|
+
const encToRemove = new Set(encryptionKeys);
|
|
1160
|
+
const revokedAt = Date.now();
|
|
1161
|
+
await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
|
|
1162
|
+
const next = (0, grantKeys_1.extractKeyPairs)(data).map((pair) => {
|
|
1163
|
+
if (pair.revoked)
|
|
1164
|
+
return pair; // already revoked; keep timestamp
|
|
1165
|
+
const targeted = signingToRemove.has(pair.signingPublicKey) ||
|
|
1166
|
+
(pair.encryptionPublicKey.length > 0 && encToRemove.has(pair.encryptionPublicKey));
|
|
1167
|
+
if (!targeted)
|
|
1168
|
+
return pair;
|
|
1169
|
+
return { ...pair, revoked: true, revokedAt };
|
|
1170
|
+
});
|
|
1171
|
+
(0, grantKeys_1.applyKeyPairFields)(data, next);
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Set or clear the label of a device's key pair, identified by its signing
|
|
1176
|
+
* public key (§6.5). An empty/whitespace `label` clears it. No-op if no pair
|
|
1177
|
+
* with that signing key exists on the user's grant.
|
|
1178
|
+
*/
|
|
1179
|
+
async setKeyPairLabel(username, signingPublicKey, label, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1180
|
+
const trimmedLabel = typeof label === "string" ? label.trim() : "";
|
|
1181
|
+
await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
|
|
1182
|
+
const pairs = (0, grantKeys_1.extractKeyPairs)(data);
|
|
1183
|
+
let changed = false;
|
|
1184
|
+
const next = pairs.map((pair) => {
|
|
1185
|
+
if (pair.signingPublicKey !== signingPublicKey)
|
|
1186
|
+
return pair;
|
|
1187
|
+
changed = true;
|
|
1188
|
+
if (trimmedLabel.length > 0) {
|
|
1189
|
+
return { ...pair, label: trimmedLabel };
|
|
1190
|
+
}
|
|
1191
|
+
// Clear the label by dropping the field entirely, preserving any
|
|
1192
|
+
// retained-revocation flags (§6.5).
|
|
1193
|
+
const { label: _dropLabel, ...rest } = pair;
|
|
1194
|
+
return rest;
|
|
1195
|
+
});
|
|
1196
|
+
// Only rewrite when this grant actually carries the targeted key, to
|
|
1197
|
+
// avoid touching other grant documents for the same user.
|
|
1198
|
+
if (changed)
|
|
1199
|
+
(0, grantKeys_1.applyKeyPairFields)(data, next);
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Request a remote wipe of specific devices (§6.5). Targets devices by signing
|
|
1204
|
+
* public key; the values are stored self-contained in
|
|
1205
|
+
* `wipeRequestedForSigningKeys` so they survive key removal. This is an
|
|
1206
|
+
* explicit, opt-in directive and is independent of revocation.
|
|
1207
|
+
*/
|
|
1208
|
+
async requestDeviceWipe(username, signingKeys, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1209
|
+
await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
|
|
1210
|
+
data.wipeRequestedForSigningKeys = unionStrings((0, grantKeys_1.extractWipeRequestedSigningKeys)(data), signingKeys);
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
/** Cancel a previously-requested device wipe for the given signing keys (§6.5). */
|
|
1214
|
+
async cancelDeviceWipe(username, signingKeys, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1215
|
+
const toCancel = new Set(signingKeys);
|
|
1216
|
+
await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
|
|
1217
|
+
data.wipeRequestedForSigningKeys = (0, grantKeys_1.extractWipeRequestedSigningKeys)(data).filter((k) => !toCancel.has(k));
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Read the current grant overview for a user, for prefilling an admin
|
|
1222
|
+
* "manage user" UI: the decrypted user-details payload (requires the tenant
|
|
1223
|
+
* default key, which the admin client holds), the active device key pairs,
|
|
1224
|
+
* and the retained revoked device key pairs (each with its `revokedAt` and
|
|
1225
|
+
* remote-wipe status). Devices are de-duplicated by signing key across the
|
|
1226
|
+
* user's grant documents (most recent wins).
|
|
1227
|
+
*/
|
|
1228
|
+
async getUserGrantOverview(username) {
|
|
1229
|
+
const grants = await this.findGrantAccessDocuments(username);
|
|
1230
|
+
const active = new Map();
|
|
1231
|
+
const revoked = new Map();
|
|
1232
|
+
let details = null;
|
|
1233
|
+
for (const grant of grants) {
|
|
1234
|
+
const data = grant.getData();
|
|
1235
|
+
const wipeRequestedKeys = new Set((0, grantKeys_1.extractWipeRequestedSigningKeys)(data));
|
|
1236
|
+
for (const pair of (0, grantKeys_1.extractActiveKeyPairs)(data)) {
|
|
1237
|
+
active.set(pair.signingPublicKey, {
|
|
1238
|
+
...pair,
|
|
1239
|
+
wipeRequested: wipeRequestedKeys.has(pair.signingPublicKey),
|
|
1240
|
+
});
|
|
1241
|
+
revoked.delete(pair.signingPublicKey);
|
|
1242
|
+
}
|
|
1243
|
+
for (const pair of (0, grantKeys_1.extractRevokedKeyPairs)(data)) {
|
|
1244
|
+
if (active.has(pair.signingPublicKey))
|
|
1245
|
+
continue;
|
|
1246
|
+
revoked.set(pair.signingPublicKey, {
|
|
1247
|
+
...pair,
|
|
1248
|
+
wipeRequested: wipeRequestedKeys.has(pair.signingPublicKey),
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
if (typeof data.user_details_encrypted === "string" && data.user_details_encrypted) {
|
|
1252
|
+
const decoded = await this.decryptUserDetailsForTenant(data);
|
|
1253
|
+
if (decoded)
|
|
1254
|
+
details = decoded;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
details,
|
|
1259
|
+
activeDevices: Array.from(active.values()),
|
|
1260
|
+
revokedDevices: Array.from(revoked.values()),
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Apply a batch of admin-signed edits to a user's grant in a single change per
|
|
1265
|
+
* grant document (docs/accesscontrol.md §6.5). In one pass this:
|
|
1266
|
+
* - rewrites `user_details_encrypted` (when `changes.details` is supplied),
|
|
1267
|
+
* - recomputes the `identity_hashes` bundle + version (the per-user backfill
|
|
1268
|
+
* trigger for grants written before the bundle existed),
|
|
1269
|
+
* - sets per-device labels (`deviceLabels`: signing key → label; empty clears),
|
|
1270
|
+
* - revokes devices (`revokeSigningKeys`: marks `revoked:true` + `revokedAt`,
|
|
1271
|
+
* retaining the pair) and restores devices (`restoreSigningKeys`: clears the
|
|
1272
|
+
* revoked flags, moving the key back to active and dropping any wipe flag),
|
|
1273
|
+
* - and sets the remote-wipe set (`wipeSigningKeys`, authoritative when
|
|
1274
|
+
* supplied) minus any restored keys.
|
|
1275
|
+
*
|
|
1276
|
+
* This is the SDK primitive behind the Haven "Manage user" dialog's batched
|
|
1277
|
+
* Save: every staged change is committed atomically per grant document.
|
|
1278
|
+
*/
|
|
1279
|
+
async updateUserGrant(username, changes, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1280
|
+
const revokeSet = new Set(changes.revokeSigningKeys ?? []);
|
|
1281
|
+
const restoreSet = new Set(changes.restoreSigningKeys ?? []);
|
|
1282
|
+
const labels = changes.deviceLabels ?? {};
|
|
1283
|
+
const now = Date.now();
|
|
1284
|
+
// Precompute async-derived fields before the (sync) per-doc mutate.
|
|
1285
|
+
const userDetailsEncrypted = changes.details
|
|
1286
|
+
? await this.encryptUserDetailsForTenant(this.buildUserDetailsPayload(username, changes.details))
|
|
1287
|
+
: null;
|
|
1288
|
+
const identityHashes = await this.computeIdentityHashes(username);
|
|
1289
|
+
await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
|
|
1290
|
+
if (userDetailsEncrypted !== null) {
|
|
1291
|
+
data.user_details_encrypted = userDetailsEncrypted;
|
|
1292
|
+
data.user_details_encrypted_key = types_1.DEFAULT_TENANT_KEY_ID;
|
|
1293
|
+
}
|
|
1294
|
+
// Recompute the $publicinfos-readable identity-hash bundle (§6.5).
|
|
1295
|
+
data.identity_hashes = identityHashes;
|
|
1296
|
+
data.identity_hashes_v = BaseMindooTenantDirectory.IDENTITY_VARIANTS_VERSION;
|
|
1297
|
+
// Per-device labels + revoke/restore flags, preserving retention.
|
|
1298
|
+
const next = (0, grantKeys_1.extractKeyPairs)(data).map((pair) => {
|
|
1299
|
+
let p = pair;
|
|
1300
|
+
if (Object.prototype.hasOwnProperty.call(labels, pair.signingPublicKey)) {
|
|
1301
|
+
const trimmed = (labels[pair.signingPublicKey] ?? "").trim();
|
|
1302
|
+
if (trimmed.length > 0) {
|
|
1303
|
+
p = { ...p, label: trimmed };
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
const { label: _dropLabel, ...rest } = p;
|
|
1307
|
+
p = rest;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (restoreSet.has(pair.signingPublicKey)) {
|
|
1311
|
+
// Un-revoke: clear the retention flags, moving the key back to active.
|
|
1312
|
+
const { revoked: _r, revokedAt: _ra, ...rest } = p;
|
|
1313
|
+
p = rest;
|
|
1314
|
+
}
|
|
1315
|
+
else if (revokeSet.has(pair.signingPublicKey) && !p.revoked) {
|
|
1316
|
+
p = { ...p, revoked: true, revokedAt: now };
|
|
1317
|
+
}
|
|
1318
|
+
return p;
|
|
1319
|
+
});
|
|
1320
|
+
(0, grantKeys_1.applyKeyPairFields)(data, next);
|
|
1321
|
+
// Remote-wipe set: authoritative when supplied (else preserve existing),
|
|
1322
|
+
// restricted to keys present on this grant doc and never for restored keys.
|
|
1323
|
+
const present = new Set(next.map((p) => p.signingPublicKey));
|
|
1324
|
+
const desiredWipe = changes.wipeSigningKeys ?? (0, grantKeys_1.extractWipeRequestedSigningKeys)(data);
|
|
1325
|
+
data.wipeRequestedForSigningKeys = Array.from(new Set(desiredWipe.filter((k) => present.has(k) && !restoreSet.has(k))));
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Predict whether the current user may perform `op` on `dbid` (§9). Pure read;
|
|
1330
|
+
* evaluates Tier 1 + Tier 2 against the head directory state, treating the
|
|
1331
|
+
* caller as the prospective author. `candidateDoc` supplies the content that
|
|
1332
|
+
* `withfields` clauses are checked against.
|
|
1333
|
+
*/
|
|
1334
|
+
async canDo(op, dbid, candidateDoc) {
|
|
1335
|
+
const currentUser = await this.tenant.getCurrentUserId();
|
|
1336
|
+
return this.evaluateClientAccess({
|
|
1337
|
+
op,
|
|
1338
|
+
dbid,
|
|
1339
|
+
signingKey: currentUser.userSigningPublicKey,
|
|
1340
|
+
trustedTime: Date.now(),
|
|
1341
|
+
isAuthor: true,
|
|
1342
|
+
beforeDoc: candidateDoc ?? null,
|
|
1343
|
+
afterDoc: candidateDoc ?? null,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Audit query (§9): would `username` have been allowed to perform `op` on
|
|
1348
|
+
* `dbid` at trusted time `at`? Evaluates against the directory state as it was
|
|
1349
|
+
* at `at`, so historical decisions can be explained.
|
|
1350
|
+
*/
|
|
1351
|
+
async wasAllowedAt(op, username, dbid, at, candidateDoc) {
|
|
1352
|
+
const node = await this.getDirectoryStateAt(at);
|
|
1353
|
+
const identity = await this.buildIdentitySet(username, { isAuthor: op === "doc_create" });
|
|
1354
|
+
return (0, evaluate_1.evaluateAccess)({
|
|
1355
|
+
op,
|
|
1356
|
+
dbid,
|
|
1357
|
+
identity,
|
|
1358
|
+
node,
|
|
1359
|
+
beforeDoc: candidateDoc ?? null,
|
|
1360
|
+
afterDoc: candidateDoc ?? null,
|
|
1361
|
+
isServer: false,
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
// -------------------------------------------------------------------------
|
|
1365
|
+
// Key distribution (admin-blind, declarative). ONE singleton document per key
|
|
1366
|
+
// (`acl_keydistribution_<keyId>`) is the source of truth for who holds the
|
|
1367
|
+
// key. A key-HOLDER wraps every key version to each recipient device's RSA
|
|
1368
|
+
// encryption public key; an ADMIN merely signs and writes the document, so an
|
|
1369
|
+
// admin outside the recipient set never sees plaintext. Syncing clients
|
|
1370
|
+
// reconcile their KeyBag against the head state (docs/accesscontrol.md §13).
|
|
1371
|
+
// -------------------------------------------------------------------------
|
|
1372
|
+
/**
|
|
1373
|
+
* All ACTIVE (non-revoked) RSA encryption public keys (PEM) for `username`
|
|
1374
|
+
* across the user's grant documents — one per active device. De-duplicated.
|
|
1375
|
+
*/
|
|
1376
|
+
async getUserEncryptionPublicKeys(username) {
|
|
1377
|
+
const grants = await this.findGrantAccessDocuments(username);
|
|
1378
|
+
const keys = new Set();
|
|
1379
|
+
for (const grant of grants) {
|
|
1380
|
+
for (const pem of (0, grantKeys_1.extractEncryptionPublicKeys)(grant.getData())) {
|
|
1381
|
+
if (pem)
|
|
1382
|
+
keys.add(pem);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
return Array.from(keys);
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Fingerprint of an RSA encryption public key (PEM): SHA-256 over the decoded
|
|
1389
|
+
* SPKI body, first 8 bytes as colon-separated hex. Matches Haven's
|
|
1390
|
+
* `getPublicKeyFingerprint`, so the device-map keys built here line up with
|
|
1391
|
+
* what Haven UIs display. Stable for a given key value across wrap (key-holder)
|
|
1392
|
+
* and reconcile (recipient) sides.
|
|
1393
|
+
*/
|
|
1394
|
+
async encryptionKeyFingerprint(pem) {
|
|
1395
|
+
const body = pem
|
|
1396
|
+
.replace(/-----BEGIN [^-]+-----/g, "")
|
|
1397
|
+
.replace(/-----END [^-]+-----/g, "")
|
|
1398
|
+
.replace(/\s+/g, "");
|
|
1399
|
+
let source;
|
|
1400
|
+
try {
|
|
1401
|
+
source = body ? this.base64ToBytes(body) : new TextEncoder().encode(pem);
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
source = new TextEncoder().encode(pem);
|
|
1405
|
+
}
|
|
1406
|
+
const subtle = this.tenant.getCryptoAdapter().getSubtle();
|
|
1407
|
+
const digest = await subtle.digest("SHA-256", source);
|
|
1408
|
+
return Array.from(new Uint8Array(digest).slice(0, 8))
|
|
1409
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
1410
|
+
.join(":");
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Wrap every stored version of `keyId` (from the caller's KeyBag) to ALL
|
|
1414
|
+
* active encryption devices of `username`, producing the exact `pushto`
|
|
1415
|
+
* device-map shape used by both the distribution doc (`pushto_users_keys`) and
|
|
1416
|
+
* the request URI (`KeyDistributionRequest.pushto[].devices`):
|
|
1417
|
+
* `deviceEncKeyFingerprint -> { versionFingerprint -> wrappedKey(b64) }`.
|
|
1418
|
+
* Throws if the caller does not hold the key or the recipient has no active
|
|
1419
|
+
* encryption device.
|
|
1420
|
+
*/
|
|
1421
|
+
async wrapKeyForUserDevices(keyId, username) {
|
|
1422
|
+
const baseTenant = this.tenant;
|
|
1423
|
+
const versions = await baseTenant.fingerprintKeyVersions(keyId);
|
|
1424
|
+
if (versions.length === 0) {
|
|
1425
|
+
throw new Error(`Cannot wrap key: key "${keyId}" is not in your KeyBag`);
|
|
1426
|
+
}
|
|
1427
|
+
const devicePems = await this.getUserEncryptionPublicKeys(username);
|
|
1428
|
+
if (devicePems.length === 0) {
|
|
1429
|
+
throw new Error(`Cannot wrap key: recipient "${username}" has no active encryption device`);
|
|
1430
|
+
}
|
|
1431
|
+
const rsa = new RSAEncryption_1.RSAEncryption(this.tenant.getCryptoAdapter(), this.logger.createChild("RSAEncryption"));
|
|
1432
|
+
const devices = {};
|
|
1433
|
+
for (const pem of devicePems) {
|
|
1434
|
+
const deviceFp = await this.encryptionKeyFingerprint(pem);
|
|
1435
|
+
const wrappedByVersion = {};
|
|
1436
|
+
for (const version of versions) {
|
|
1437
|
+
const wrapped = await rsa.encrypt(version.bytes, pem);
|
|
1438
|
+
wrappedByVersion[version.fingerprint] = this.bytesToBase64(wrapped);
|
|
1439
|
+
}
|
|
1440
|
+
devices[deviceFp] = wrappedByVersion;
|
|
1441
|
+
}
|
|
1442
|
+
return {
|
|
1443
|
+
username,
|
|
1444
|
+
username_hash: await this.hashUsernameForWrite(username),
|
|
1445
|
+
devices,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* The canonical write-time hash for `username` (the value stored in
|
|
1450
|
+
* `pushto_users_hashes` / `pullfrom_users_hashes`). Exposed so callers (the
|
|
1451
|
+
* Haven dialog/session) can build `pullfrom` entries for users they are
|
|
1452
|
+
* removing.
|
|
1453
|
+
*/
|
|
1454
|
+
async getUsernameHash(username) {
|
|
1455
|
+
return this.hashUsernameForWrite(username);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* The version manifest (`{createdAt, fingerprint}`) of `keyId` from the
|
|
1459
|
+
* caller's KeyBag. The single source of truth for the distribution's
|
|
1460
|
+
* `keyVersions`. Empty when the key is not held.
|
|
1461
|
+
*/
|
|
1462
|
+
async getKeyVersionManifest(keyId) {
|
|
1463
|
+
const versions = await this.tenant.fingerprintKeyVersions(keyId);
|
|
1464
|
+
return versions.map((v) => ({ createdAt: v.createdAt, fingerprint: v.fingerprint }));
|
|
1465
|
+
}
|
|
1466
|
+
/** Encrypt a UTF-8 string with the tenant default key, base64-encoded. */
|
|
1467
|
+
async encryptToDefaultField(plaintext) {
|
|
1468
|
+
const payload = new TextEncoder().encode(plaintext);
|
|
1469
|
+
const encrypted = await this.tenant.encryptPayload(payload, types_1.DEFAULT_TENANT_KEY_ID);
|
|
1470
|
+
return this.uint8ArrayToBase64(encrypted);
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Admin-sign and upsert the singleton `acl_keydistribution_<keyId>` document
|
|
1474
|
+
* from a {@link KeyDistributionRequest} (built in-dialog or decoded from a
|
|
1475
|
+
* request URI). The full desired state is written each time. Validates the
|
|
1476
|
+
* structural invariants plus, against the directory, that every `pushto` user
|
|
1477
|
+
* has an active grant and every active device is covered by wrapped material.
|
|
1478
|
+
*/
|
|
1479
|
+
async publishKeyDistribution(request, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1480
|
+
const keyId = request.keyId;
|
|
1481
|
+
// Fold per-recipient device maps into the doc's flat `<hash>|<deviceFp>` map.
|
|
1482
|
+
const pushtoUsersKeys = {};
|
|
1483
|
+
for (const p of request.pushto) {
|
|
1484
|
+
for (const [deviceFp, wrapped] of Object.entries(p.devices)) {
|
|
1485
|
+
pushtoUsersKeys[`${p.username_hash}|${deviceFp}`] = wrapped;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
const pushtoHashes = request.pushto.map((p) => p.username_hash);
|
|
1489
|
+
const pullfromHashes = request.pullfrom.map((p) => p.username_hash);
|
|
1490
|
+
(0, types_3.validateKeyDistribution)({
|
|
1491
|
+
keyId,
|
|
1492
|
+
keyVersions: request.keyVersions,
|
|
1493
|
+
pushto_users_hashes: pushtoHashes,
|
|
1494
|
+
pullfrom_users_hashes: pullfromHashes,
|
|
1495
|
+
pushto_users_keys: pushtoUsersKeys,
|
|
1496
|
+
});
|
|
1497
|
+
// Directory-level validation: every pushto user must have an active grant,
|
|
1498
|
+
// and every active encryption device must have wrapped material (otherwise a
|
|
1499
|
+
// newly-added device could not decrypt — re-wrap is required).
|
|
1500
|
+
const manifestFps = new Set(request.keyVersions.map((v) => v.fingerprint));
|
|
1501
|
+
for (const p of request.pushto) {
|
|
1502
|
+
const userKeys = await this.getUserPublicKeys(p.username);
|
|
1503
|
+
if (!userKeys) {
|
|
1504
|
+
throw new Error(`Cannot publish: pushto user "${p.username}" has no active grant`);
|
|
1505
|
+
}
|
|
1506
|
+
const activePems = await this.getUserEncryptionPublicKeys(p.username);
|
|
1507
|
+
for (const pem of activePems) {
|
|
1508
|
+
const deviceFp = await this.encryptionKeyFingerprint(pem);
|
|
1509
|
+
const wrapped = p.devices[deviceFp];
|
|
1510
|
+
if (!wrapped) {
|
|
1511
|
+
throw new Error(`Cannot publish: pushto user "${p.username}" has an active device without wrapped key material (re-wrap required)`);
|
|
1512
|
+
}
|
|
1513
|
+
const covered = Object.keys(wrapped);
|
|
1514
|
+
if (covered.length !== manifestFps.size || covered.some((fp) => !manifestFps.has(fp))) {
|
|
1515
|
+
throw new Error(`Cannot publish: device coverage for "${p.username}" does not match the version manifest`);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
const titleEncrypted = await this.encryptToDefaultField(request.title ?? keyId);
|
|
1520
|
+
const commentEncrypted = request.comment
|
|
1521
|
+
? await this.encryptToDefaultField(request.comment)
|
|
1522
|
+
: undefined;
|
|
1523
|
+
const pushtoUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushto.map((p) => p.username)));
|
|
1524
|
+
const pullfromUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfrom.map((p) => p.username)));
|
|
1525
|
+
const docId = (0, types_3.aclKeyDistributionDocId)(keyId);
|
|
1526
|
+
const baseTenant = this.tenant;
|
|
1527
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1528
|
+
const adminSigningKeyPair = {
|
|
1529
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
1530
|
+
privateKey: administrationPrivateKey,
|
|
1531
|
+
};
|
|
1532
|
+
const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
|
|
1533
|
+
await directoryDB.changeDoc(doc, async (d) => {
|
|
1534
|
+
const data = d.getData();
|
|
1535
|
+
data.form = types_3.ACCESS_CONTROL_FORM;
|
|
1536
|
+
data.type = types_3.KEY_DISTRIBUTION_TYPE;
|
|
1537
|
+
data.keyId = keyId;
|
|
1538
|
+
data.keyVersions = request.keyVersions.map((v) => ({
|
|
1539
|
+
createdAt: v.createdAt,
|
|
1540
|
+
fingerprint: v.fingerprint,
|
|
1541
|
+
}));
|
|
1542
|
+
data.preparedByPublicKey = request.preparedByPublicKey;
|
|
1543
|
+
data.title_encrypted = titleEncrypted;
|
|
1544
|
+
data.title_encrypted_key = "default";
|
|
1545
|
+
if (commentEncrypted !== undefined) {
|
|
1546
|
+
data.comment_encrypted = commentEncrypted;
|
|
1547
|
+
data.comment_encrypted_key = "default";
|
|
1548
|
+
}
|
|
1549
|
+
else {
|
|
1550
|
+
delete data.comment_encrypted;
|
|
1551
|
+
delete data.comment_encrypted_key;
|
|
1552
|
+
}
|
|
1553
|
+
data.pushto_users_hashes = pushtoHashes;
|
|
1554
|
+
data.pushto_users_encrypted = pushtoUsernamesEncrypted;
|
|
1555
|
+
data.pushto_users_encrypted_key = "default";
|
|
1556
|
+
data.pushto_users_keys = pushtoUsersKeys;
|
|
1557
|
+
data.pullfrom_users_hashes = pullfromHashes;
|
|
1558
|
+
data.pullfrom_users_encrypted = pullfromUsernamesEncrypted;
|
|
1559
|
+
data.pullfrom_users_encrypted_key = "default";
|
|
1560
|
+
}, {
|
|
1561
|
+
signingKeyPair: adminSigningKeyPair,
|
|
1562
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
1563
|
+
});
|
|
1564
|
+
await this.updateUnifiedCache();
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Decrypt a `<field>_encrypted` JSON array of strings on a distribution doc.
|
|
1568
|
+
* Returns null when the field is missing/unreadable (e.g. the tenant default
|
|
1569
|
+
* key is not held).
|
|
1570
|
+
*/
|
|
1571
|
+
async decryptUsernameArray(data, fieldName) {
|
|
1572
|
+
const decoded = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, fieldName);
|
|
1573
|
+
if (decoded === null)
|
|
1574
|
+
return null;
|
|
1575
|
+
try {
|
|
1576
|
+
const parsed = JSON.parse(decoded);
|
|
1577
|
+
if (Array.isArray(parsed)) {
|
|
1578
|
+
return parsed.filter((v) => typeof v === "string");
|
|
1579
|
+
}
|
|
1580
|
+
return null;
|
|
1581
|
+
}
|
|
1582
|
+
catch {
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/** Parse a raw distribution doc's data into a {@link KeyDistributionView}. */
|
|
1587
|
+
async toKeyDistributionView(data) {
|
|
1588
|
+
if (data.type !== types_3.KEY_DISTRIBUTION_TYPE || typeof data.keyId !== "string")
|
|
1589
|
+
return null;
|
|
1590
|
+
const keyVersions = Array.isArray(data.keyVersions)
|
|
1591
|
+
? data.keyVersions
|
|
1592
|
+
.filter((v) => !!v && typeof v.fingerprint === "string")
|
|
1593
|
+
.map((v) => ({ createdAt: Number(v.createdAt) || 0, fingerprint: v.fingerprint }))
|
|
1594
|
+
: [];
|
|
1595
|
+
const title = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "title_encrypted");
|
|
1596
|
+
const comment = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "comment_encrypted");
|
|
1597
|
+
return {
|
|
1598
|
+
keyId: data.keyId,
|
|
1599
|
+
title,
|
|
1600
|
+
comment,
|
|
1601
|
+
keyVersions,
|
|
1602
|
+
preparedByPublicKey: typeof data.preparedByPublicKey === "string" ? data.preparedByPublicKey : "",
|
|
1603
|
+
pushto_users_hashes: Array.isArray(data.pushto_users_hashes)
|
|
1604
|
+
? data.pushto_users_hashes.filter((h) => typeof h === "string")
|
|
1605
|
+
: [],
|
|
1606
|
+
pushto_users_keys: data.pushto_users_keys && typeof data.pushto_users_keys === "object"
|
|
1607
|
+
? data.pushto_users_keys
|
|
1608
|
+
: {},
|
|
1609
|
+
pullfrom_users_hashes: Array.isArray(data.pullfrom_users_hashes)
|
|
1610
|
+
? data.pullfrom_users_hashes.filter((h) => typeof h === "string")
|
|
1611
|
+
: [],
|
|
1612
|
+
pushtoUsernames: await this.decryptUsernameArray(data, "pushto_users_encrypted"),
|
|
1613
|
+
pullfromUsernames: await this.decryptUsernameArray(data, "pullfrom_users_encrypted"),
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* List all key-distribution documents at the directory head as
|
|
1618
|
+
* {@link KeyDistributionView}s (decrypting display fields when the tenant
|
|
1619
|
+
* default key is held; null-tolerant otherwise).
|
|
1620
|
+
*/
|
|
1621
|
+
async listKeyDistributions() {
|
|
1622
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1623
|
+
await directoryDB.syncStoreChanges();
|
|
1624
|
+
await this.updateUnifiedCache();
|
|
1625
|
+
const allIds = await directoryDB.getAllDocumentIds();
|
|
1626
|
+
const ids = allIds.filter((id) => id.startsWith(types_3.ACL_KEY_DISTRIBUTION_PREFIX));
|
|
1627
|
+
const views = [];
|
|
1628
|
+
for (const id of ids) {
|
|
1629
|
+
let doc;
|
|
1630
|
+
try {
|
|
1631
|
+
doc = await directoryDB.getDocument(id);
|
|
1632
|
+
}
|
|
1633
|
+
catch {
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
if (!doc)
|
|
1637
|
+
continue;
|
|
1638
|
+
const view = await this.toKeyDistributionView(doc.getData());
|
|
1639
|
+
if (view)
|
|
1640
|
+
views.push(view);
|
|
1641
|
+
}
|
|
1642
|
+
return views;
|
|
1643
|
+
}
|
|
1644
|
+
/** Delete the singleton distribution document for `keyId` (admin-signed). */
|
|
1645
|
+
async deleteKeyDistribution(keyId, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1646
|
+
const baseTenant = this.tenant;
|
|
1647
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1648
|
+
const adminSigningKeyPair = {
|
|
1649
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
1650
|
+
privateKey: administrationPrivateKey,
|
|
1651
|
+
};
|
|
1652
|
+
try {
|
|
1653
|
+
await directoryDB.deleteDocument((0, types_3.aclKeyDistributionDocId)(keyId), {
|
|
1654
|
+
signingKeyPair: adminSigningKeyPair,
|
|
1655
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
catch (error) {
|
|
1659
|
+
this.logger.debug(`deleteKeyDistribution: ${keyId} not found or already deleted: ${error}`);
|
|
1660
|
+
}
|
|
1661
|
+
await this.updateUnifiedCache();
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Internal entry point for `MindooTenant.reconcileKeyDistributionsForCurrentUser`'s
|
|
1665
|
+
* import pass ({@link KeyBagReconciler}). Unlocks this directory's OWN tenant
|
|
1666
|
+
* session key and merges the key versions pushed to `username`. The key never
|
|
1667
|
+
* crosses an API boundary — callers cannot supply a foreign one. Returns the
|
|
1668
|
+
* imported/removed ids; both empty when the host is locked (no session key).
|
|
1669
|
+
*/
|
|
1670
|
+
async reconcileImportedKeysForCurrentUser(username) {
|
|
1671
|
+
const baseTenant = this.tenant;
|
|
1672
|
+
const key = await baseTenant.getEncryptionPrivateKeyForReconcile();
|
|
1673
|
+
if (!key) {
|
|
1674
|
+
this.logger.debug("reconcileImportedKeysForCurrentUser: no encryption key available; " +
|
|
1675
|
+
"skipping import pass (revoke pass still ran)");
|
|
1676
|
+
return { imported: [], removed: [] };
|
|
1677
|
+
}
|
|
1678
|
+
return this.reconcileKeyDistributionsWithKey(username, key);
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Reconcile the local KeyBag against the directory head for `username`
|
|
1682
|
+
* (docs/accesscontrol.md §13), driven by an already-imported RSA-OAEP private
|
|
1683
|
+
* {@link CryptoKey} (usage `["decrypt"]`). Pure function of (head, bag):
|
|
1684
|
+
* idempotent and convergent, so an offline client catches up after one sync.
|
|
1685
|
+
* Private: the only callers are this class's own
|
|
1686
|
+
* {@link reconcileImportedKeysForCurrentUser}, which sources the key from the
|
|
1687
|
+
* tenant, so no foreign key is ever applied to the bag. Per
|
|
1688
|
+
* `acl_keydistribution_` doc:
|
|
1689
|
+
*
|
|
1690
|
+
* - **pullfrom** (wins): remove the whole key id from the bag (all versions),
|
|
1691
|
+
* matching the server's key-id-grain blacklist; a no-op when the bag does
|
|
1692
|
+
* not hold it. The KeyBag change feed triggers visibility reconciliation,
|
|
1693
|
+
* purging the scope once the key is gone. Never touches protected ids.
|
|
1694
|
+
* - **pushto**: unwrap own device entries, VERIFY each unwrapped version's
|
|
1695
|
+
* fingerprint against the manifest (reject + log on mismatch), and merge the
|
|
1696
|
+
* missing versions (never destructive). Self-healing: a locally deleted
|
|
1697
|
+
* pushed key is re-imported here.
|
|
1698
|
+
*
|
|
1699
|
+
* Returns the key ids imported and removed.
|
|
1700
|
+
*/
|
|
1701
|
+
async reconcileKeyDistributionsWithKey(username, encryptionPrivateCryptoKey) {
|
|
1702
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1703
|
+
await directoryDB.syncStoreChanges();
|
|
1704
|
+
await this.updateUnifiedCache();
|
|
1705
|
+
if (this.keyDistributionCache.size === 0)
|
|
1706
|
+
return { imported: [], removed: [] };
|
|
1707
|
+
const myHashes = new Set(await this.usernameHashCandidates(username));
|
|
1708
|
+
const baseTenant = this.tenant;
|
|
1709
|
+
const privateKey = encryptionPrivateCryptoKey;
|
|
1710
|
+
const rsa = new RSAEncryption_1.RSAEncryption(this.tenant.getCryptoAdapter(), this.logger.createChild("RSAEncryption"));
|
|
1711
|
+
const imported = [];
|
|
1712
|
+
const removed = [];
|
|
1713
|
+
for (const [keyId, entry] of this.keyDistributionCache.entries()) {
|
|
1714
|
+
if (types_3.PROTECTED_DISTRIBUTION_KEY_IDS.includes(keyId))
|
|
1715
|
+
continue;
|
|
1716
|
+
const manifest = entry.keyVersions;
|
|
1717
|
+
const meInPull = entry.pullfrom_users_hashes.some((h) => myHashes.has(h));
|
|
1718
|
+
const meInPush = entry.pushto_users_hashes.some((h) => myHashes.has(h));
|
|
1719
|
+
// Pull wins over push if a CRDT merge ever overlapped the lists. Revocation
|
|
1720
|
+
// removes the whole key id from the bag (matching the server's key-id-grain
|
|
1721
|
+
// blacklist); a no-op when the bag does not hold it.
|
|
1722
|
+
if (meInPull) {
|
|
1723
|
+
const didRemove = await baseTenant.removeNamedDecryptionKey(keyId);
|
|
1724
|
+
if (didRemove)
|
|
1725
|
+
removed.push(keyId);
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
if (!meInPush)
|
|
1729
|
+
continue;
|
|
1730
|
+
const wrappedMap = entry.pushto_users_keys;
|
|
1731
|
+
// Collect device entries addressed to me (by hash prefix). We do not know
|
|
1732
|
+
// which device is ours without trying, so attempt to unwrap each; only our
|
|
1733
|
+
// private key succeeds.
|
|
1734
|
+
const myEntries = Object.entries(wrappedMap).filter(([entryKey]) => {
|
|
1735
|
+
const hash = entryKey.split("|")[0];
|
|
1736
|
+
return myHashes.has(hash);
|
|
1737
|
+
});
|
|
1738
|
+
if (myEntries.length === 0)
|
|
1739
|
+
continue;
|
|
1740
|
+
const collected = new Map();
|
|
1741
|
+
for (const [, wrappedByVersion] of myEntries) {
|
|
1742
|
+
for (const ref of manifest) {
|
|
1743
|
+
if (collected.has(ref.fingerprint))
|
|
1744
|
+
continue;
|
|
1745
|
+
const wrappedB64 = wrappedByVersion[ref.fingerprint];
|
|
1746
|
+
if (typeof wrappedB64 !== "string")
|
|
1747
|
+
continue;
|
|
1748
|
+
try {
|
|
1749
|
+
const bytes = await rsa.decrypt(this.base64ToBytes(wrappedB64), privateKey);
|
|
1750
|
+
const actualFp = await baseTenant.fingerprintKeyBytes(bytes);
|
|
1751
|
+
if (actualFp !== ref.fingerprint) {
|
|
1752
|
+
this.logger.warn(`reconcileKeyDistributions: fingerprint mismatch for ${keyId}@${ref.fingerprint} (got ${actualFp}); rejecting`);
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
collected.set(ref.fingerprint, { bytes, keyVersionCreatedAt: ref.createdAt });
|
|
1756
|
+
}
|
|
1757
|
+
catch {
|
|
1758
|
+
// Not our device entry (or corrupt); try the next one.
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (collected.size === manifest.length)
|
|
1762
|
+
break;
|
|
1763
|
+
}
|
|
1764
|
+
if (collected.size === 0)
|
|
1765
|
+
continue;
|
|
1766
|
+
const count = await baseTenant.importDeliveredDecryptionKeyVersions(keyId, Array.from(collected.values()));
|
|
1767
|
+
if (count > 0)
|
|
1768
|
+
imported.push(keyId);
|
|
1769
|
+
}
|
|
1770
|
+
return { imported, removed };
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Whether `keyId` is managed by a distribution at the directory head AND the
|
|
1774
|
+
* current/given user is in its `pushto` list (status is derived, never
|
|
1775
|
+
* persisted in the KeyBag).
|
|
1776
|
+
*/
|
|
1777
|
+
async getManagedKeyIds(username) {
|
|
1778
|
+
const myHashes = new Set(await this.usernameHashCandidates(username));
|
|
1779
|
+
const views = await this.listKeyDistributions();
|
|
1780
|
+
return views
|
|
1781
|
+
.filter((v) => v.pushto_users_hashes.some((h) => myHashes.has(h)))
|
|
1782
|
+
.map((v) => v.keyId);
|
|
1783
|
+
}
|
|
1784
|
+
// -------------------------------------------------------------------------
|
|
1785
|
+
// App distribution (docs/accesscontrol.md §13). Mirrors key distribution; the
|
|
1786
|
+
// payload is a Haven application registration and recipient lists support
|
|
1787
|
+
// both users and groups, resolved client-side at reconcile time.
|
|
1788
|
+
// -------------------------------------------------------------------------
|
|
1789
|
+
/**
|
|
1790
|
+
* Admin-sign and upsert the singleton `acl_appdistribution_<appId>` document
|
|
1791
|
+
* from an {@link AppDistributionRequest} (built in-dialog or decoded from a
|
|
1792
|
+
* request URI). The full desired state is written each time. Validates the
|
|
1793
|
+
* structural invariants plus, against the directory, that every `pushto` user
|
|
1794
|
+
* has an active grant.
|
|
1795
|
+
*/
|
|
1796
|
+
async publishAppDistribution(request, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1797
|
+
const appId = request.appId;
|
|
1798
|
+
const pushtoUserHashes = await Promise.all(request.pushtoUsernames.map((u) => this.hashUsernameForWrite(u)));
|
|
1799
|
+
const pullfromUserHashes = await Promise.all(request.pullfromUsernames.map((u) => this.hashUsernameForWrite(u)));
|
|
1800
|
+
const pushtoGroupHashes = await Promise.all(request.pushtoGroups.map((g) => this.hashUsernameForWrite(this.normalizeGroupName(g))));
|
|
1801
|
+
const pullfromGroupHashes = await Promise.all(request.pullfromGroups.map((g) => this.hashUsernameForWrite(this.normalizeGroupName(g))));
|
|
1802
|
+
(0, types_3.validateAppDistribution)({
|
|
1803
|
+
appId,
|
|
1804
|
+
pushto_users_hashes: pushtoUserHashes,
|
|
1805
|
+
pullfrom_users_hashes: pullfromUserHashes,
|
|
1806
|
+
pushto_groups_hashes: pushtoGroupHashes,
|
|
1807
|
+
pullfrom_groups_hashes: pullfromGroupHashes,
|
|
1808
|
+
});
|
|
1809
|
+
// Directory-level validation: every pushto user must have an active grant.
|
|
1810
|
+
for (const username of request.pushtoUsernames) {
|
|
1811
|
+
const userKeys = await this.getUserPublicKeys(username);
|
|
1812
|
+
if (!userKeys) {
|
|
1813
|
+
throw new Error(`Cannot publish: pushto user "${username}" has no active grant`);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
const appidEncrypted = await this.encryptToDefaultField(appId);
|
|
1817
|
+
const versionEncrypted = await this.encryptToDefaultField(request.version ?? "");
|
|
1818
|
+
const titleEncrypted = await this.encryptToDefaultField(request.title ?? appId);
|
|
1819
|
+
const commentEncrypted = request.comment
|
|
1820
|
+
? await this.encryptToDefaultField(request.comment)
|
|
1821
|
+
: undefined;
|
|
1822
|
+
const appdataEncrypted = await this.encryptToDefaultField(JSON.stringify(request.appData ?? {}));
|
|
1823
|
+
const pushtoUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushtoUsernames));
|
|
1824
|
+
const pushtoGroupsEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushtoGroups));
|
|
1825
|
+
const pullfromUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfromUsernames));
|
|
1826
|
+
const pullfromGroupsEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfromGroups));
|
|
1827
|
+
const docId = (0, types_3.aclAppDistributionDocId)(appId);
|
|
1828
|
+
const baseTenant = this.tenant;
|
|
1829
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1830
|
+
const adminSigningKeyPair = {
|
|
1831
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
1832
|
+
privateKey: administrationPrivateKey,
|
|
1833
|
+
};
|
|
1834
|
+
const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
|
|
1835
|
+
await directoryDB.changeDoc(doc, async (d) => {
|
|
1836
|
+
const data = d.getData();
|
|
1837
|
+
data.form = types_3.ACCESS_CONTROL_FORM;
|
|
1838
|
+
data.type = types_3.APP_DISTRIBUTION_TYPE;
|
|
1839
|
+
data.appId = appId;
|
|
1840
|
+
data.appid_encrypted = appidEncrypted;
|
|
1841
|
+
data.appid_encrypted_key = "default";
|
|
1842
|
+
data.version_encrypted = versionEncrypted;
|
|
1843
|
+
data.version_encrypted_key = "default";
|
|
1844
|
+
data.title_encrypted = titleEncrypted;
|
|
1845
|
+
data.title_encrypted_key = "default";
|
|
1846
|
+
if (commentEncrypted !== undefined) {
|
|
1847
|
+
data.comment_encrypted = commentEncrypted;
|
|
1848
|
+
data.comment_encrypted_key = "default";
|
|
1849
|
+
}
|
|
1850
|
+
else {
|
|
1851
|
+
delete data.comment_encrypted;
|
|
1852
|
+
delete data.comment_encrypted_key;
|
|
1853
|
+
}
|
|
1854
|
+
data.appdata_encrypted = appdataEncrypted;
|
|
1855
|
+
data.appdata_encrypted_key = "default";
|
|
1856
|
+
data.preparedByPublicKey = request.preparedByPublicKey;
|
|
1857
|
+
data.pushto_users_hashes = pushtoUserHashes;
|
|
1858
|
+
data.pushto_users_encrypted = pushtoUsernamesEncrypted;
|
|
1859
|
+
data.pushto_users_encrypted_key = "default";
|
|
1860
|
+
data.pushto_groups_hashes = pushtoGroupHashes;
|
|
1861
|
+
data.pushto_groups_encrypted = pushtoGroupsEncrypted;
|
|
1862
|
+
data.pushto_groups_encrypted_key = "default";
|
|
1863
|
+
data.pullfrom_users_hashes = pullfromUserHashes;
|
|
1864
|
+
data.pullfrom_users_encrypted = pullfromUsernamesEncrypted;
|
|
1865
|
+
data.pullfrom_users_encrypted_key = "default";
|
|
1866
|
+
data.pullfrom_groups_hashes = pullfromGroupHashes;
|
|
1867
|
+
data.pullfrom_groups_encrypted = pullfromGroupsEncrypted;
|
|
1868
|
+
data.pullfrom_groups_encrypted_key = "default";
|
|
1869
|
+
}, {
|
|
1870
|
+
signingKeyPair: adminSigningKeyPair,
|
|
1871
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
1872
|
+
});
|
|
1873
|
+
await this.updateUnifiedCache();
|
|
1874
|
+
}
|
|
1875
|
+
/** Parse a raw app-distribution doc's data into an {@link AppDistributionView}. */
|
|
1876
|
+
async toAppDistributionView(data) {
|
|
1877
|
+
if (data.type !== types_3.APP_DISTRIBUTION_TYPE || typeof data.appId !== "string")
|
|
1878
|
+
return null;
|
|
1879
|
+
const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
|
|
1880
|
+
const title = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "title_encrypted");
|
|
1881
|
+
const comment = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "comment_encrypted");
|
|
1882
|
+
const version = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "version_encrypted");
|
|
1883
|
+
let appData = null;
|
|
1884
|
+
const appdataDecoded = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "appdata_encrypted");
|
|
1885
|
+
if (appdataDecoded !== null) {
|
|
1886
|
+
try {
|
|
1887
|
+
appData = JSON.parse(appdataDecoded);
|
|
1888
|
+
}
|
|
1889
|
+
catch {
|
|
1890
|
+
appData = null;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
return {
|
|
1894
|
+
appId: data.appId,
|
|
1895
|
+
title,
|
|
1896
|
+
comment,
|
|
1897
|
+
version,
|
|
1898
|
+
appData,
|
|
1899
|
+
preparedByPublicKey: typeof data.preparedByPublicKey === "string" ? data.preparedByPublicKey : "",
|
|
1900
|
+
pushto_users_hashes: stringArray(data.pushto_users_hashes),
|
|
1901
|
+
pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
|
|
1902
|
+
pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
|
|
1903
|
+
pullfrom_groups_hashes: stringArray(data.pullfrom_groups_hashes),
|
|
1904
|
+
pushtoUsernames: await this.decryptUsernameArray(data, "pushto_users_encrypted"),
|
|
1905
|
+
pushtoGroups: await this.decryptUsernameArray(data, "pushto_groups_encrypted"),
|
|
1906
|
+
pullfromUsernames: await this.decryptUsernameArray(data, "pullfrom_users_encrypted"),
|
|
1907
|
+
pullfromGroups: await this.decryptUsernameArray(data, "pullfrom_groups_encrypted"),
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* List all app-distribution documents at the directory head as
|
|
1912
|
+
* {@link AppDistributionView}s (decrypting display fields when the tenant
|
|
1913
|
+
* default key is held; null-tolerant otherwise).
|
|
1914
|
+
*/
|
|
1915
|
+
async listAppDistributions() {
|
|
1916
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1917
|
+
await directoryDB.syncStoreChanges();
|
|
1918
|
+
await this.updateUnifiedCache();
|
|
1919
|
+
const allIds = await directoryDB.getAllDocumentIds();
|
|
1920
|
+
const ids = allIds.filter((id) => id.startsWith(types_3.ACL_APP_DISTRIBUTION_PREFIX));
|
|
1921
|
+
const views = [];
|
|
1922
|
+
for (const id of ids) {
|
|
1923
|
+
let doc;
|
|
1924
|
+
try {
|
|
1925
|
+
doc = await directoryDB.getDocument(id);
|
|
1926
|
+
}
|
|
1927
|
+
catch {
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
if (!doc)
|
|
1931
|
+
continue;
|
|
1932
|
+
const view = await this.toAppDistributionView(doc.getData());
|
|
1933
|
+
if (view)
|
|
1934
|
+
views.push(view);
|
|
1935
|
+
}
|
|
1936
|
+
return views;
|
|
1937
|
+
}
|
|
1938
|
+
/** Delete the singleton distribution document for `appId` (admin-signed). */
|
|
1939
|
+
async deleteAppDistribution(appId, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
1940
|
+
const baseTenant = this.tenant;
|
|
1941
|
+
const directoryDB = await this.getDirectoryDB();
|
|
1942
|
+
const adminSigningKeyPair = {
|
|
1943
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
1944
|
+
privateKey: administrationPrivateKey,
|
|
1945
|
+
};
|
|
1946
|
+
try {
|
|
1947
|
+
await directoryDB.deleteDocument((0, types_3.aclAppDistributionDocId)(appId), {
|
|
1948
|
+
signingKeyPair: adminSigningKeyPair,
|
|
1949
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
catch (error) {
|
|
1953
|
+
this.logger.debug(`deleteAppDistribution: ${appId} not found or already deleted: ${error}`);
|
|
1954
|
+
}
|
|
1955
|
+
await this.updateUnifiedCache();
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Build the set of recipient-matching hashes for `username`: the user's own
|
|
1959
|
+
* username-hash candidates plus the hash candidates of every group the user
|
|
1960
|
+
* belongs to (resolved against the directory head). App-distribution
|
|
1961
|
+
* `pushto`/`pullfrom` group hashes are written as the v3 hash of the
|
|
1962
|
+
* normalized group name, which is exactly the v3 candidate of the group name,
|
|
1963
|
+
* so a member intersects a group target here.
|
|
1964
|
+
*/
|
|
1965
|
+
async appDistributionIdentityHashes(username) {
|
|
1966
|
+
const hashes = new Set(await this.usernameHashCandidates(username));
|
|
1967
|
+
const groups = await this.resolveGroupsForUser(username);
|
|
1968
|
+
for (const group of groups) {
|
|
1969
|
+
for (const h of await this.usernameHashCandidates(group)) {
|
|
1970
|
+
hashes.add(h);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
return hashes;
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* The per-user app-distribution reconcile plan at the directory head
|
|
1977
|
+
* (docs/accesscontrol.md §13): `have` is every app the user is entitled to
|
|
1978
|
+
* (matched by `pushto` user/group and NOT by `pullfrom` — pull wins on any
|
|
1979
|
+
* overlap), `notHave` is every other distributed app id (the user must not
|
|
1980
|
+
* keep it). Drives the Haven install/update/remove pass after a directory
|
|
1981
|
+
* sync. Idempotent and convergent.
|
|
1982
|
+
*/
|
|
1983
|
+
async getAppDistributionsForCurrentUser(username) {
|
|
1984
|
+
const myHashes = await this.appDistributionIdentityHashes(username);
|
|
1985
|
+
const views = await this.listAppDistributions();
|
|
1986
|
+
const have = [];
|
|
1987
|
+
const notHave = [];
|
|
1988
|
+
for (const view of views) {
|
|
1989
|
+
const meInPull = view.pullfrom_users_hashes.some((h) => myHashes.has(h)) ||
|
|
1990
|
+
view.pullfrom_groups_hashes.some((h) => myHashes.has(h));
|
|
1991
|
+
const meInPush = view.pushto_users_hashes.some((h) => myHashes.has(h)) ||
|
|
1992
|
+
view.pushto_groups_hashes.some((h) => myHashes.has(h));
|
|
1993
|
+
if (meInPush && !meInPull) {
|
|
1994
|
+
have.push({
|
|
1995
|
+
appId: view.appId,
|
|
1996
|
+
title: view.title ?? "",
|
|
1997
|
+
version: view.version ?? "",
|
|
1998
|
+
appData: view.appData,
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
else {
|
|
2002
|
+
notHave.push(view.appId);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
return { have, notHave };
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* The app ids the active/given user is entitled to receive via `pushto` at the
|
|
2009
|
+
* directory head (status is derived, never persisted). Convenience wrapper
|
|
2010
|
+
* over {@link getAppDistributionsForCurrentUser}.
|
|
2011
|
+
*/
|
|
2012
|
+
async getManagedAppIds(username) {
|
|
2013
|
+
const plan = await this.getAppDistributionsForCurrentUser(username);
|
|
2014
|
+
return plan.have.map((entry) => entry.appId);
|
|
2015
|
+
}
|
|
2016
|
+
// -------------------------------------------------------------------------
|
|
2017
|
+
// Sync setup policy. Mirrors app distribution; the payload is a list of
|
|
2018
|
+
// database ids the targeted clients should have on their Haven Sync page,
|
|
2019
|
+
// and the per-policy `mode` controls whether the seeding is just initial
|
|
2020
|
+
// (user may change/disable) or permanently enforced (locked to bidirectional).
|
|
2021
|
+
// Reconciled client-side after the directory syncs; no server enforcement.
|
|
2022
|
+
// -------------------------------------------------------------------------
|
|
2023
|
+
/**
|
|
2024
|
+
* Admin-sign and upsert the `acl_syncsetuppolicy_<policyId>` document from a
|
|
2025
|
+
* {@link SyncSetupPolicyRequest} (built in-dialog or decoded from a request
|
|
2026
|
+
* URI). The full desired state is written each time. Validates the structural
|
|
2027
|
+
* invariants plus, against the directory, that every `pushto` user has an
|
|
2028
|
+
* active grant.
|
|
2029
|
+
*/
|
|
2030
|
+
async publishSyncSetupPolicy(request, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
2031
|
+
const policyId = request.policyId;
|
|
2032
|
+
const mode = request.mode === "permanent" ? "permanent" : "initial";
|
|
2033
|
+
const databaseIds = Array.isArray(request.databaseIds)
|
|
2034
|
+
? request.databaseIds.map((id) => id.trim()).filter((id) => id.length > 0)
|
|
2035
|
+
: [];
|
|
2036
|
+
const pushtoUserHashes = await Promise.all(request.pushtoUsernames.map((u) => this.hashUsernameForWrite(u)));
|
|
2037
|
+
const pullfromUserHashes = await Promise.all(request.pullfromUsernames.map((u) => this.hashUsernameForWrite(u)));
|
|
2038
|
+
const pushtoGroupHashes = await Promise.all(request.pushtoGroups.map((g) => this.hashUsernameForWrite(this.normalizeGroupName(g))));
|
|
2039
|
+
const pullfromGroupHashes = await Promise.all(request.pullfromGroups.map((g) => this.hashUsernameForWrite(this.normalizeGroupName(g))));
|
|
2040
|
+
(0, types_3.validateSyncSetupPolicy)({
|
|
2041
|
+
policyId,
|
|
2042
|
+
mode,
|
|
2043
|
+
databaseIds,
|
|
2044
|
+
pushto_users_hashes: pushtoUserHashes,
|
|
2045
|
+
pullfrom_users_hashes: pullfromUserHashes,
|
|
2046
|
+
pushto_groups_hashes: pushtoGroupHashes,
|
|
2047
|
+
pullfrom_groups_hashes: pullfromGroupHashes,
|
|
2048
|
+
});
|
|
2049
|
+
// Directory-level validation: every pushto user must have an active grant.
|
|
2050
|
+
for (const username of request.pushtoUsernames) {
|
|
2051
|
+
const userKeys = await this.getUserPublicKeys(username);
|
|
2052
|
+
if (!userKeys) {
|
|
2053
|
+
throw new Error(`Cannot publish: pushto user "${username}" has no active grant`);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
const policyidEncrypted = await this.encryptToDefaultField(policyId);
|
|
2057
|
+
const titleEncrypted = await this.encryptToDefaultField(request.title ?? policyId);
|
|
2058
|
+
const commentEncrypted = request.comment
|
|
2059
|
+
? await this.encryptToDefaultField(request.comment)
|
|
2060
|
+
: undefined;
|
|
2061
|
+
const databaseidsEncrypted = await this.encryptToDefaultField(JSON.stringify(databaseIds));
|
|
2062
|
+
const pushtoUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushtoUsernames));
|
|
2063
|
+
const pushtoGroupsEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushtoGroups));
|
|
2064
|
+
const pullfromUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfromUsernames));
|
|
2065
|
+
const pullfromGroupsEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfromGroups));
|
|
2066
|
+
const docId = (0, types_3.aclSyncSetupPolicyDocId)(policyId);
|
|
2067
|
+
const baseTenant = this.tenant;
|
|
2068
|
+
const directoryDB = await this.getDirectoryDB();
|
|
2069
|
+
const adminSigningKeyPair = {
|
|
2070
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
2071
|
+
privateKey: administrationPrivateKey,
|
|
2072
|
+
};
|
|
2073
|
+
const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
|
|
2074
|
+
await directoryDB.changeDoc(doc, async (d) => {
|
|
2075
|
+
const data = d.getData();
|
|
2076
|
+
data.form = types_3.ACCESS_CONTROL_FORM;
|
|
2077
|
+
data.type = types_3.SYNC_SETUP_POLICY_TYPE;
|
|
2078
|
+
data.policyId = policyId;
|
|
2079
|
+
data.policyid_encrypted = policyidEncrypted;
|
|
2080
|
+
data.policyid_encrypted_key = "default";
|
|
2081
|
+
data.mode = mode;
|
|
2082
|
+
data.title_encrypted = titleEncrypted;
|
|
2083
|
+
data.title_encrypted_key = "default";
|
|
2084
|
+
if (commentEncrypted !== undefined) {
|
|
2085
|
+
data.comment_encrypted = commentEncrypted;
|
|
2086
|
+
data.comment_encrypted_key = "default";
|
|
2087
|
+
}
|
|
2088
|
+
else {
|
|
2089
|
+
delete data.comment_encrypted;
|
|
2090
|
+
delete data.comment_encrypted_key;
|
|
2091
|
+
}
|
|
2092
|
+
data.databaseids_encrypted = databaseidsEncrypted;
|
|
2093
|
+
data.databaseids_encrypted_key = "default";
|
|
2094
|
+
data.preparedByPublicKey = request.preparedByPublicKey;
|
|
2095
|
+
data.pushto_users_hashes = pushtoUserHashes;
|
|
2096
|
+
data.pushto_users_encrypted = pushtoUsernamesEncrypted;
|
|
2097
|
+
data.pushto_users_encrypted_key = "default";
|
|
2098
|
+
data.pushto_groups_hashes = pushtoGroupHashes;
|
|
2099
|
+
data.pushto_groups_encrypted = pushtoGroupsEncrypted;
|
|
2100
|
+
data.pushto_groups_encrypted_key = "default";
|
|
2101
|
+
data.pullfrom_users_hashes = pullfromUserHashes;
|
|
2102
|
+
data.pullfrom_users_encrypted = pullfromUsernamesEncrypted;
|
|
2103
|
+
data.pullfrom_users_encrypted_key = "default";
|
|
2104
|
+
data.pullfrom_groups_hashes = pullfromGroupHashes;
|
|
2105
|
+
data.pullfrom_groups_encrypted = pullfromGroupsEncrypted;
|
|
2106
|
+
data.pullfrom_groups_encrypted_key = "default";
|
|
2107
|
+
}, {
|
|
2108
|
+
signingKeyPair: adminSigningKeyPair,
|
|
2109
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
2110
|
+
});
|
|
2111
|
+
await this.updateUnifiedCache();
|
|
2112
|
+
}
|
|
2113
|
+
/** Parse a raw sync-setup-policy doc's data into a {@link SyncSetupPolicyView}. */
|
|
2114
|
+
async toSyncSetupPolicyView(data) {
|
|
2115
|
+
if (data.type !== types_3.SYNC_SETUP_POLICY_TYPE || typeof data.policyId !== "string")
|
|
2116
|
+
return null;
|
|
2117
|
+
const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
|
|
2118
|
+
const title = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "title_encrypted");
|
|
2119
|
+
const comment = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "comment_encrypted");
|
|
2120
|
+
let databaseIds = null;
|
|
2121
|
+
const databaseIdsDecoded = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "databaseids_encrypted");
|
|
2122
|
+
if (databaseIdsDecoded !== null) {
|
|
2123
|
+
try {
|
|
2124
|
+
const parsed = JSON.parse(databaseIdsDecoded);
|
|
2125
|
+
databaseIds = Array.isArray(parsed)
|
|
2126
|
+
? parsed.filter((id) => typeof id === "string")
|
|
2127
|
+
: null;
|
|
2128
|
+
}
|
|
2129
|
+
catch {
|
|
2130
|
+
databaseIds = null;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return {
|
|
2134
|
+
policyId: data.policyId,
|
|
2135
|
+
mode: data.mode === "permanent" ? "permanent" : "initial",
|
|
2136
|
+
title,
|
|
2137
|
+
comment,
|
|
2138
|
+
databaseIds,
|
|
2139
|
+
preparedByPublicKey: typeof data.preparedByPublicKey === "string" ? data.preparedByPublicKey : "",
|
|
2140
|
+
pushto_users_hashes: stringArray(data.pushto_users_hashes),
|
|
2141
|
+
pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
|
|
2142
|
+
pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
|
|
2143
|
+
pullfrom_groups_hashes: stringArray(data.pullfrom_groups_hashes),
|
|
2144
|
+
pushtoUsernames: await this.decryptUsernameArray(data, "pushto_users_encrypted"),
|
|
2145
|
+
pushtoGroups: await this.decryptUsernameArray(data, "pushto_groups_encrypted"),
|
|
2146
|
+
pullfromUsernames: await this.decryptUsernameArray(data, "pullfrom_users_encrypted"),
|
|
2147
|
+
pullfromGroups: await this.decryptUsernameArray(data, "pullfrom_groups_encrypted"),
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* List all sync-setup-policy documents at the directory head as
|
|
2152
|
+
* {@link SyncSetupPolicyView}s (decrypting display fields when the tenant
|
|
2153
|
+
* default key is held; null-tolerant otherwise).
|
|
2154
|
+
*/
|
|
2155
|
+
async listSyncSetupPolicies() {
|
|
2156
|
+
const directoryDB = await this.getDirectoryDB();
|
|
2157
|
+
await directoryDB.syncStoreChanges();
|
|
2158
|
+
await this.updateUnifiedCache();
|
|
2159
|
+
const allIds = await directoryDB.getAllDocumentIds();
|
|
2160
|
+
const ids = allIds.filter((id) => id.startsWith(types_3.ACL_SYNC_SETUP_POLICY_PREFIX));
|
|
2161
|
+
const views = [];
|
|
2162
|
+
for (const id of ids) {
|
|
2163
|
+
let doc;
|
|
2164
|
+
try {
|
|
2165
|
+
doc = await directoryDB.getDocument(id);
|
|
2166
|
+
}
|
|
2167
|
+
catch {
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
if (!doc)
|
|
2171
|
+
continue;
|
|
2172
|
+
const view = await this.toSyncSetupPolicyView(doc.getData());
|
|
2173
|
+
if (view)
|
|
2174
|
+
views.push(view);
|
|
2175
|
+
}
|
|
2176
|
+
return views;
|
|
2177
|
+
}
|
|
2178
|
+
/** Delete the sync-setup-policy document for `policyId` (admin-signed). */
|
|
2179
|
+
async deleteSyncSetupPolicy(policyId, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
2180
|
+
const baseTenant = this.tenant;
|
|
2181
|
+
const directoryDB = await this.getDirectoryDB();
|
|
2182
|
+
const adminSigningKeyPair = {
|
|
2183
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
2184
|
+
privateKey: administrationPrivateKey,
|
|
2185
|
+
};
|
|
2186
|
+
try {
|
|
2187
|
+
await directoryDB.deleteDocument((0, types_3.aclSyncSetupPolicyDocId)(policyId), {
|
|
2188
|
+
signingKeyPair: adminSigningKeyPair,
|
|
2189
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
catch (error) {
|
|
2193
|
+
this.logger.debug(`deleteSyncSetupPolicy: ${policyId} not found or already deleted: ${error}`);
|
|
2194
|
+
}
|
|
2195
|
+
await this.updateUnifiedCache();
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* The per-user sync-setup reconcile plan at the directory head: the union of
|
|
2199
|
+
* databases the user is targeted for across all policies (matched by `pushto`
|
|
2200
|
+
* user/group), each carrying the effective lock state. A database is locked
|
|
2201
|
+
* when a `permanent` policy targets it AND the user is not released via
|
|
2202
|
+
* `pullfrom`; lock wins on overlap. A user only present in `pullfrom` (never
|
|
2203
|
+
* in `pushto`) is not targeted. Drives the Haven Sync-page seed/lock pass
|
|
2204
|
+
* after a directory sync. Idempotent and convergent.
|
|
2205
|
+
*/
|
|
2206
|
+
async getSyncSetupForCurrentUser(username) {
|
|
2207
|
+
const myHashes = await this.appDistributionIdentityHashes(username);
|
|
2208
|
+
const views = await this.listSyncSetupPolicies();
|
|
2209
|
+
const lockByDb = new Map();
|
|
2210
|
+
for (const view of views) {
|
|
2211
|
+
const meInPush = view.pushto_users_hashes.some((h) => myHashes.has(h)) ||
|
|
2212
|
+
view.pushto_groups_hashes.some((h) => myHashes.has(h));
|
|
2213
|
+
if (!meInPush)
|
|
2214
|
+
continue;
|
|
2215
|
+
const meInPull = view.pullfrom_users_hashes.some((h) => myHashes.has(h)) ||
|
|
2216
|
+
view.pullfrom_groups_hashes.some((h) => myHashes.has(h));
|
|
2217
|
+
const locked = view.mode === "permanent" && !meInPull;
|
|
2218
|
+
for (const databaseId of view.databaseIds ?? []) {
|
|
2219
|
+
const id = databaseId.trim();
|
|
2220
|
+
if (!id)
|
|
2221
|
+
continue;
|
|
2222
|
+
lockByDb.set(id, (lockByDb.get(id) ?? false) || locked);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
const databases = Array.from(lockByDb.entries()).map(([databaseId, locked]) => ({
|
|
2226
|
+
databaseId,
|
|
2227
|
+
locked,
|
|
2228
|
+
}));
|
|
2229
|
+
return { databases };
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* The decryption key ids revoked for `username` at the directory head: every
|
|
2233
|
+
* `acl_keydistribution_<keyId>` whose `pullfrom_users_hashes` matches one of
|
|
2234
|
+
* the user's hash candidates (docs/accesscontrol.md §13). Protected ids (the
|
|
2235
|
+
* tenant default / public-infos keys) are never reported. Read straight from
|
|
2236
|
+
* the {@link keyDistributionCache} (O(cache size), no doc scan) so the sync
|
|
2237
|
+
* server can apply the per-user blacklist on every pull/push, and the client
|
|
2238
|
+
* can bulk-remove these ids from its KeyBag.
|
|
2239
|
+
*/
|
|
2240
|
+
async getRevokedDecryptionKeyIdsForUser(username) {
|
|
2241
|
+
await this.updateUnifiedCache();
|
|
2242
|
+
const myHashes = new Set(await this.usernameHashCandidates(username));
|
|
2243
|
+
return this.collectRevokedKeyIdsForHashes(myHashes);
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Signing-key variant of {@link getRevokedDecryptionKeyIdsForUser}, for the
|
|
2247
|
+
* sync server which authenticates principals by signing public key. The tenant
|
|
2248
|
+
* admin and any non-user (service) key are never revoked, so they get an empty
|
|
2249
|
+
* list.
|
|
2250
|
+
*
|
|
2251
|
+
* Matching is done purely in hash space against the grant's precomputed,
|
|
2252
|
+
* `$publicinfos`-readable `identity_hashes` bundle (docs/accesscontrol.md §6.5,
|
|
2253
|
+
* §13). This is the ONLY form that works on a real sync server: the server
|
|
2254
|
+
* holds only the `$publicinfos` key, never the tenant default key, so it can
|
|
2255
|
+
* NEVER decrypt `user_details_encrypted` — {@link getUserBySigningPublicKey}
|
|
2256
|
+
* returns the bare `username_hash` as `username`. Re-hashing that single hash
|
|
2257
|
+
* (routing through {@link getRevokedDecryptionKeyIdsForUser}, the old behavior)
|
|
2258
|
+
* produces hash-of-hash values that cannot match the multi-variant
|
|
2259
|
+
* `pullfrom_users_hashes`, so the blacklist silently did nothing on the server.
|
|
2260
|
+
* The precomputed bundle, written from the cleartext name at grant time, is
|
|
2261
|
+
* server-readable and carries every variant.
|
|
2262
|
+
*/
|
|
2263
|
+
async getRevokedDecryptionKeyIdsForSigningKey(signingKey) {
|
|
2264
|
+
if (signingKey === this.tenant.getAdministrationPublicKey())
|
|
2265
|
+
return [];
|
|
2266
|
+
const lookup = await this.getUserBySigningPublicKey(signingKey);
|
|
2267
|
+
if (!lookup)
|
|
2268
|
+
return [];
|
|
2269
|
+
const myHashes = await this.identityHashesForLookup(lookup);
|
|
2270
|
+
if (myHashes.size === 0)
|
|
2271
|
+
return [];
|
|
2272
|
+
return this.collectRevokedKeyIdsForHashes(myHashes);
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* The hash set identifying a looked-up grant, for hash-space matching against
|
|
2276
|
+
* directory rules / blacklists (docs/accesscontrol.md §6.5). Prefers the
|
|
2277
|
+
* precomputed `$publicinfos`-readable `identity_hashes` bundle (the v1/v2/v3
|
|
2278
|
+
* hashes of every DN-hierarchy username variant), so it works on a sync server
|
|
2279
|
+
* that cannot read the cleartext name. Legacy grants written before the bundle
|
|
2280
|
+
* existed (`identityHashesV` 0/absent) carry only the single exact-match
|
|
2281
|
+
* `username_hash`; when the cleartext name IS available (a client that holds
|
|
2282
|
+
* the tenant key) we additionally recompute the scheme variants so matching is
|
|
2283
|
+
* no weaker than before the bundle existed.
|
|
2284
|
+
*/
|
|
2285
|
+
async identityHashesForLookup(lookup) {
|
|
2286
|
+
const hashes = new Set(lookup.identityHashes ?? []);
|
|
2287
|
+
if ((lookup.identityHashesV ?? 0) === 0 && lookup.username) {
|
|
2288
|
+
for (const h of await this.usernameHashCandidates(lookup.username)) {
|
|
2289
|
+
hashes.add(h);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
return hashes;
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Hash-space core of the revoked-key blacklist (docs/accesscontrol.md §13):
|
|
2296
|
+
* every `acl_keydistribution_<keyId>` whose `pullfrom_users_hashes` intersects
|
|
2297
|
+
* `myHashes`. Protected ids (the tenant default / public-infos keys) are never
|
|
2298
|
+
* reported. Reads straight from {@link keyDistributionCache} (O(cache size),
|
|
2299
|
+
* no doc scan); the caller must have refreshed it first (both entry points do,
|
|
2300
|
+
* via `updateUnifiedCache` / `getUserBySigningPublicKey`).
|
|
2301
|
+
*/
|
|
2302
|
+
collectRevokedKeyIdsForHashes(myHashes) {
|
|
2303
|
+
const revoked = [];
|
|
2304
|
+
for (const [keyId, entry] of this.keyDistributionCache.entries()) {
|
|
2305
|
+
if (types_3.PROTECTED_DISTRIBUTION_KEY_IDS.includes(keyId))
|
|
2306
|
+
continue;
|
|
2307
|
+
if (entry.pullfrom_users_hashes.some((h) => myHashes.has(h))) {
|
|
2308
|
+
revoked.push(keyId);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return revoked;
|
|
2312
|
+
}
|
|
2313
|
+
/** Encode bytes as base64 (mirrors RSAEncryption's transport encoding). */
|
|
2314
|
+
bytesToBase64(bytes) {
|
|
2315
|
+
let binary = "";
|
|
2316
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
2317
|
+
binary += String.fromCharCode(bytes[i]);
|
|
2318
|
+
}
|
|
2319
|
+
return btoa(binary);
|
|
2320
|
+
}
|
|
2321
|
+
/** Decode a base64 string into bytes. */
|
|
2322
|
+
base64ToBytes(base64) {
|
|
2323
|
+
const binary = atob(base64);
|
|
2324
|
+
const bytes = new Uint8Array(binary.length);
|
|
2325
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2326
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2327
|
+
}
|
|
2328
|
+
return bytes;
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* Load the user's active grant document and apply an admin-signed edit to its
|
|
2332
|
+
* key/wipe arrays. Shared by {@link addUserKeys}, {@link removeUserKeys},
|
|
2333
|
+
* {@link requestDeviceWipe}, and {@link cancelDeviceWipe}.
|
|
2334
|
+
*/
|
|
2335
|
+
async editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, mutate) {
|
|
2336
|
+
const grants = await this.findGrantAccessDocuments(username);
|
|
2337
|
+
if (grants.length === 0) {
|
|
2338
|
+
throw new Error(`No active grant found for user "${username}"`);
|
|
224
2339
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
2340
|
+
const baseTenant = this.tenant;
|
|
2341
|
+
const directoryDB = await this.getDirectoryDB();
|
|
2342
|
+
const adminSigningKeyPair = {
|
|
2343
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
2344
|
+
privateKey: administrationPrivateKey,
|
|
2345
|
+
};
|
|
2346
|
+
for (const grant of grants) {
|
|
2347
|
+
const doc = await directoryDB.getDocument(grant.getId());
|
|
2348
|
+
await directoryDB.changeDoc(doc, async (d) => {
|
|
2349
|
+
mutate(d.getData());
|
|
2350
|
+
}, { signingKeyPair: adminSigningKeyPair, signingKeyPassword: administrationPrivateKeyPassword });
|
|
233
2351
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
2352
|
+
await this.updateUnifiedCache();
|
|
2353
|
+
}
|
|
2354
|
+
/** Load an ACL document by its fixed id, creating it (admin-signed) if absent. */
|
|
2355
|
+
async getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword) {
|
|
2356
|
+
try {
|
|
2357
|
+
const existing = await directoryDB.getDocument(docId);
|
|
2358
|
+
if (existing)
|
|
2359
|
+
return existing;
|
|
237
2360
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
2361
|
+
catch {
|
|
2362
|
+
// Not found; fall through to create.
|
|
2363
|
+
}
|
|
2364
|
+
return directoryDB.createDocument({
|
|
2365
|
+
id: docId,
|
|
2366
|
+
signingKeyPair: adminSigningKeyPair,
|
|
2367
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
2368
|
+
decryptionKeyId: types_1.PUBLIC_INFOS_KEY_ID,
|
|
2369
|
+
});
|
|
241
2370
|
}
|
|
242
2371
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* No signature verification is needed since the DB already enforces admin-only access.
|
|
2372
|
+
* Resolve the identity set for a signing key (admin, granted user, or trusted
|
|
2373
|
+
* non-user key), shared by the server and client evaluation paths.
|
|
246
2374
|
*/
|
|
247
|
-
async
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
2375
|
+
async identitySetForSigningKey(signingKey, node, opts) {
|
|
2376
|
+
// The tenant admin key is the root of trust and is resolved even without a
|
|
2377
|
+
// grant document (it authors the directory itself).
|
|
2378
|
+
const adminKey = this.tenant.getAdministrationPublicKey();
|
|
2379
|
+
const isAdmin = signingKey === adminKey;
|
|
2380
|
+
// Audit #4 (revocation lag): resolve the acting identity strictly from the
|
|
2381
|
+
// directory state at the entry's trusted time `T`. A signing key that was
|
|
2382
|
+
// not an ACTIVE grant at `T` carries only the pseudo-tokens
|
|
2383
|
+
// (`$everyone`/`$author`), even though the live `userLookupCache`
|
|
2384
|
+
// intentionally still remembers its username/group label after revocation.
|
|
2385
|
+
// Without this gate, a just-revoked key would keep matching username/group
|
|
2386
|
+
// allow-rules until the trust cache refreshed (up to
|
|
2387
|
+
// DIRECTORY_SYNC_INTERVAL_MS later). The `node` is the time-`T` snapshot
|
|
2388
|
+
// whose `bySigningKey` only contains keys whose grant is active at `T`.
|
|
2389
|
+
const grantedAtT = node.bySigningKey.has(signingKey);
|
|
2390
|
+
// Resolve the author's username from their signing key. Trusted non-user
|
|
2391
|
+
// keys (e.g. server-to-server sync identities) have no grant; treat them as
|
|
2392
|
+
// an empty identity that still carries `$everyone`/`$author`. Keys that were
|
|
2393
|
+
// revoked (or not yet granted) at `T` are likewise reduced to the minimal
|
|
2394
|
+
// identity so they cannot match name/group rules.
|
|
2395
|
+
const lookup = isAdmin || !grantedAtT ? null : await this.getUserBySigningPublicKey(signingKey);
|
|
2396
|
+
if (!lookup) {
|
|
2397
|
+
return this.minimalIdentitySet({ isAdmin, isAuthor: opts.isAuthor });
|
|
259
2398
|
}
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (data.form === "useroperation") {
|
|
267
|
-
// Check if this is a grant access document
|
|
268
|
-
if (data.type === "grantaccess" &&
|
|
269
|
-
data.userSigningPublicKey &&
|
|
270
|
-
typeof data.userSigningPublicKey === "string") {
|
|
271
|
-
const userPublicKey = data.userSigningPublicKey;
|
|
272
|
-
this.logger.debug(`Cache: adding trusted key from grant access document`);
|
|
273
|
-
// Mark as active (true) unless already revoked
|
|
274
|
-
if (!this.trustedKeysCache.has(userPublicKey) || this.trustedKeysCache.get(userPublicKey) === true) {
|
|
275
|
-
this.trustedKeysCache.set(userPublicKey, true);
|
|
276
|
-
}
|
|
277
|
-
// Store mapping for future revocation lookups (persisted across iterations)
|
|
278
|
-
this.grantDocIdToPublicKey.set(doc.getId(), userPublicKey);
|
|
279
|
-
const userLookup = await this.buildUserLookup(data);
|
|
280
|
-
if (userLookup) {
|
|
281
|
-
this.userLookupCache.set(userPublicKey, userLookup);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
// Check if this is a revoke access document
|
|
285
|
-
if (data.type === "revokeaccess" &&
|
|
286
|
-
data.revokeDocId &&
|
|
287
|
-
typeof data.revokeDocId === "string") {
|
|
288
|
-
const revokedDocId = data.revokeDocId;
|
|
289
|
-
// Find the public key for this grant doc ID and mark as revoked
|
|
290
|
-
// Use the persistent mapping that survives across iterations
|
|
291
|
-
const revokedPublicKey = this.grantDocIdToPublicKey.get(revokedDocId);
|
|
292
|
-
if (revokedPublicKey) {
|
|
293
|
-
this.logger.debug(`Cache: revoking key for doc ${revokedDocId}`);
|
|
294
|
-
this.trustedKeysCache.set(revokedPublicKey, false);
|
|
295
|
-
}
|
|
296
|
-
else {
|
|
297
|
-
this.logger.warn(`Cache: revocation for unknown grant doc ${revokedDocId}`);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
// Process group documents
|
|
302
|
-
if (data.form === "group" &&
|
|
303
|
-
data.type === "group" &&
|
|
304
|
-
data.groupName &&
|
|
305
|
-
typeof data.groupName === "string") {
|
|
306
|
-
const normalizedGroupName = this.normalizeGroupName(data.groupName);
|
|
307
|
-
if (!groupDocsByName.has(normalizedGroupName)) {
|
|
308
|
-
groupDocsByName.set(normalizedGroupName, []);
|
|
309
|
-
}
|
|
310
|
-
groupDocsByName.get(normalizedGroupName).push(doc);
|
|
311
|
-
}
|
|
312
|
-
// Process tenant settings (no signature verification needed - Automerge handles merging)
|
|
313
|
-
if (data.form === "tenantsettings") {
|
|
314
|
-
// Just overwrite - last one seen will be the latest
|
|
315
|
-
this.tenantSettingsCache = doc;
|
|
316
|
-
}
|
|
317
|
-
// Process DB settings (no signature verification needed - Automerge handles merging)
|
|
318
|
-
if (data.form === "dbsettings" && data.dbid && typeof data.dbid === "string") {
|
|
319
|
-
const dbId = data.dbid;
|
|
320
|
-
// Just overwrite - last one seen will be the latest
|
|
321
|
-
this.dbSettingsCache.set(dbId, doc);
|
|
322
|
-
}
|
|
323
|
-
// Update cursor after each document
|
|
324
|
-
this.unifiedCacheLastCursor = cursor;
|
|
2399
|
+
// A client holding the tenant default key decrypts `user_details_encrypted`,
|
|
2400
|
+
// so `lookup.username` is the cleartext name (`details` is populated). Build
|
|
2401
|
+
// the full identity set, including the cleartext usernames/groups needed for
|
|
2402
|
+
// Tier 2 `${user.*}` placeholder resolution.
|
|
2403
|
+
if (lookup.details !== null && lookup.username) {
|
|
2404
|
+
return this.buildIdentitySet(lookup.username, { isAdmin, isAuthor: opts.isAuthor });
|
|
325
2405
|
}
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
2406
|
+
// A $publicinfos-only sync server can NEVER decrypt `user_details_encrypted`
|
|
2407
|
+
// (it is under the tenant default key), so `getUserBySigningPublicKey`
|
|
2408
|
+
// degrades `username` to the bare `username_hash`. Re-hashing that single
|
|
2409
|
+
// hash (the old `buildIdentitySet(username)` path) produces hash-of-hash
|
|
2410
|
+
// values that match no rule, silently breaking server-side Tier 1
|
|
2411
|
+
// username/group rule matching. Build the matching hashes from the
|
|
2412
|
+
// precomputed, $publicinfos-readable `identity_hashes` bundle instead, which
|
|
2413
|
+
// carries every DN-variant hash (docs/accesscontrol.md §6.5, §7). The server
|
|
2414
|
+
// ignores Tier 2 (placeholder) rules, so the empty cleartext
|
|
2415
|
+
// usernames/groups are never read here.
|
|
2416
|
+
return this.buildIdentitySetFromHashes(await this.identityHashesForLookup(lookup), { isAdmin, isAuthor: opts.isAuthor });
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* The identity set for a key with no resolvable username (admin or a trusted
|
|
2420
|
+
* service key): only the pseudo-tokens apply.
|
|
2421
|
+
*/
|
|
2422
|
+
minimalIdentitySet(opts) {
|
|
2423
|
+
const hashes = new Set();
|
|
2424
|
+
hashes.add(types_3.PSEUDO_TOKEN_EVERYONE);
|
|
2425
|
+
if (opts.isAdmin)
|
|
2426
|
+
hashes.add(types_3.PSEUDO_TOKEN_ADMIN);
|
|
2427
|
+
if (opts.isAuthor)
|
|
2428
|
+
hashes.add(types_3.PSEUDO_TOKEN_AUTHOR);
|
|
2429
|
+
return { username: "", usernames: [], groups: [], hashes };
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Build an identity set for access evaluation purely from a set of identity
|
|
2433
|
+
* hashes (docs/accesscontrol.md §6.5/§7), without the cleartext username. Used
|
|
2434
|
+
* on a $publicinfos-only sync server, which cannot read the cleartext name but
|
|
2435
|
+
* must still match Tier 1 username/group rules in hash space. Group membership
|
|
2436
|
+
* is resolved from the hashes (group docs store `members_hashes` and a
|
|
2437
|
+
* `$publicinfos`-readable `groupName`), and each resolved group's name hashes
|
|
2438
|
+
* are folded in so group-targeted rules match. Cleartext
|
|
2439
|
+
* `username`/`usernames`/`groups` are left empty: the server defers every
|
|
2440
|
+
* Tier 2 (placeholder) rule to clients, so they are never read here.
|
|
2441
|
+
*/
|
|
2442
|
+
async buildIdentitySetFromHashes(userHashes, opts) {
|
|
2443
|
+
await this.updateUnifiedCache();
|
|
2444
|
+
const hashes = new Set(userHashes);
|
|
2445
|
+
const groups = await this.resolveGroupsFromVariantHashes(userHashes);
|
|
2446
|
+
for (const group of groups) {
|
|
2447
|
+
for (const h of await this.usernameHashCandidates(group)) {
|
|
2448
|
+
hashes.add(h);
|
|
369
2449
|
}
|
|
370
2450
|
}
|
|
2451
|
+
hashes.add(types_3.PSEUDO_TOKEN_EVERYONE);
|
|
2452
|
+
if (opts.isAdmin)
|
|
2453
|
+
hashes.add(types_3.PSEUDO_TOKEN_ADMIN);
|
|
2454
|
+
if (opts.isAuthor)
|
|
2455
|
+
hashes.add(types_3.PSEUDO_TOKEN_AUTHOR);
|
|
2456
|
+
return { username: "", usernames: [], groups, hashes };
|
|
371
2457
|
}
|
|
372
2458
|
/**
|
|
373
2459
|
* Normalize group name to lowercase for case-insensitive comparison.
|
|
374
2460
|
*/
|
|
375
2461
|
normalizeGroupName(name) {
|
|
376
|
-
|
|
2462
|
+
// NFKC-normalize before lowercasing so visually-equivalent Unicode group
|
|
2463
|
+
// names collapse to one key (homoglyph/normalization defense). ASCII names
|
|
2464
|
+
// are unchanged.
|
|
2465
|
+
return name.normalize("NFKC").toLowerCase();
|
|
377
2466
|
}
|
|
378
2467
|
/**
|
|
379
2468
|
* Generate username variants with wildcards for hierarchical matching.
|
|
@@ -404,15 +2493,27 @@ class BaseMindooTenantDirectory {
|
|
|
404
2493
|
* Stops on cycles with a console warning.
|
|
405
2494
|
*/
|
|
406
2495
|
async resolveGroupsForUser(username) {
|
|
2496
|
+
// Generate username variants for matching and compute their hashes.
|
|
2497
|
+
// Include both legacy (v1) and salted (v2) hash forms for every variant so
|
|
2498
|
+
// membership written under either scheme is matched (docs/accesscontrol.md §6.5).
|
|
2499
|
+
const usernameVariants = this.generateUsernameVariants(username);
|
|
2500
|
+
const variantHashes = new Set((await Promise.all(usernameVariants.map(v => this.usernameHashCandidates(v)))).flat());
|
|
2501
|
+
return this.resolveGroupsFromVariantHashes(variantHashes);
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Hash-space core of {@link resolveGroupsForUser}: given the set of a user's
|
|
2505
|
+
* username-variant hashes (v1+v2 of every DN-hierarchy variant), recursively
|
|
2506
|
+
* resolve all groups that contain the user (directly or via nested groups).
|
|
2507
|
+
* Works purely from hashes, so the server can resolve group membership for a
|
|
2508
|
+
* key-based reader using the precomputed `identity_hashes` bundle — no
|
|
2509
|
+
* cleartext username required (docs/accesscontrol.md §6.5).
|
|
2510
|
+
*/
|
|
2511
|
+
async resolveGroupsFromVariantHashes(variantHashes) {
|
|
407
2512
|
const resultGroups = new Set();
|
|
408
2513
|
// Sync changes and update cache
|
|
409
2514
|
const directoryDB = await this.getDirectoryDB();
|
|
410
2515
|
await directoryDB.syncStoreChanges();
|
|
411
2516
|
await this.updateUnifiedCache();
|
|
412
|
-
// Generate username variants for matching and compute their hashes
|
|
413
|
-
const usernameVariants = this.generateUsernameVariants(username);
|
|
414
|
-
// Hash all variants for comparison with members_hashes
|
|
415
|
-
const variantHashes = new Set(await Promise.all(usernameVariants.map(v => this.hashUsername(v))));
|
|
416
2517
|
// First pass: find all groups that directly contain the user or username variants (by hash)
|
|
417
2518
|
const directGroups = new Set();
|
|
418
2519
|
for (const [groupName, groupData] of this.groupsCache.entries()) {
|
|
@@ -438,14 +2539,14 @@ class BaseMindooTenantDirectory {
|
|
|
438
2539
|
continue;
|
|
439
2540
|
}
|
|
440
2541
|
visitedGroups.add(childGroup);
|
|
441
|
-
// Hash the child group name for comparison
|
|
442
|
-
const
|
|
2542
|
+
// Hash the child group name for comparison (both legacy + salted forms).
|
|
2543
|
+
const childGroupHashes = await this.usernameHashCandidates(childGroup);
|
|
443
2544
|
// Find all groups that contain this child group as a member (by hash)
|
|
444
2545
|
for (const [parentGroupName, parentGroupData] of this.groupsCache.entries()) {
|
|
445
2546
|
if (resultGroups.has(parentGroupName)) {
|
|
446
2547
|
continue; // Already found this group
|
|
447
2548
|
}
|
|
448
|
-
if (parentGroupData.members_hashes.includes(
|
|
2549
|
+
if (childGroupHashes.some(h => parentGroupData.members_hashes.includes(h))) {
|
|
449
2550
|
resultGroups.add(parentGroupName);
|
|
450
2551
|
nextGroups.add(parentGroupName);
|
|
451
2552
|
}
|
|
@@ -464,18 +2565,22 @@ class BaseMindooTenantDirectory {
|
|
|
464
2565
|
*/
|
|
465
2566
|
async getUserPublicKeys(username) {
|
|
466
2567
|
this.logger.debug(`Getting public keys for username: ${username}`);
|
|
467
|
-
// Find all
|
|
468
|
-
|
|
2568
|
+
// Find all grant access documents for this user, then keep only those that
|
|
2569
|
+
// still carry signing keys. Revocation removes keys in place (§6.5), so a
|
|
2570
|
+
// grant with an empty key array is a revoked grant and must be ignored.
|
|
2571
|
+
const grantDocs = await this.findGrantAccessDocuments(username);
|
|
2572
|
+
const activeDocs = grantDocs.filter((doc) => (0, grantKeys_1.extractSigningPublicKeys)(doc.getData()).length > 0);
|
|
469
2573
|
if (activeDocs.length === 0) {
|
|
470
2574
|
this.logger.debug(`No active grant access documents found for username: ${username}`);
|
|
471
2575
|
return null;
|
|
472
2576
|
}
|
|
473
|
-
// Use the
|
|
474
|
-
// In most cases there should only be one active grant access document per user
|
|
2577
|
+
// Use the most recent active grant access document (most recent registration).
|
|
2578
|
+
// In most cases there should only be one active grant access document per user.
|
|
475
2579
|
const doc = activeDocs[activeDocs.length - 1];
|
|
476
2580
|
const data = doc.getData();
|
|
477
|
-
|
|
478
|
-
const
|
|
2581
|
+
// Prefer the array key form, falling back to the legacy scalar fields (§6.5).
|
|
2582
|
+
const signingPublicKey = (0, grantKeys_1.extractSigningPublicKeys)(data)[0] ?? null;
|
|
2583
|
+
const encryptionPublicKey = (0, grantKeys_1.extractEncryptionPublicKeys)(data)[0] ?? null;
|
|
479
2584
|
if (typeof signingPublicKey !== "string" || typeof encryptionPublicKey !== "string") {
|
|
480
2585
|
this.logger.error(`Invalid key data in grant access document for username: ${username}`);
|
|
481
2586
|
return null;
|
|
@@ -494,6 +2599,66 @@ class BaseMindooTenantDirectory {
|
|
|
494
2599
|
await this.updateUnifiedCache();
|
|
495
2600
|
return this.userLookupCache.get(publicKey) ?? null;
|
|
496
2601
|
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Resolve the full set of signing keys relevant to authenticating a device for
|
|
2604
|
+
* `username` (docs/accesscontrol.md §6.5):
|
|
2605
|
+
* - `active`: keys currently granted (the device may sync normally), and
|
|
2606
|
+
* - `wipeRequested`: keys an admin has targeted for remote wipe.
|
|
2607
|
+
*
|
|
2608
|
+
* The two sets are independent: a stolen device may still be active yet
|
|
2609
|
+
* wipe-targeted, or revoked (removed from `active`) yet still wipe-targeted.
|
|
2610
|
+
* The server uses this to let a wipe-targeted (possibly revoked) device
|
|
2611
|
+
* authenticate just far enough to receive the wipe directive.
|
|
2612
|
+
*/
|
|
2613
|
+
async getUserSigningKeyUniverse(username) {
|
|
2614
|
+
const grants = await this.findGrantAccessDocuments(username);
|
|
2615
|
+
const active = new Set();
|
|
2616
|
+
const wipeRequested = new Set();
|
|
2617
|
+
for (const grant of grants) {
|
|
2618
|
+
const data = grant.getData();
|
|
2619
|
+
for (const key of (0, grantKeys_1.extractSigningPublicKeys)(data))
|
|
2620
|
+
active.add(key);
|
|
2621
|
+
for (const key of (0, grantKeys_1.extractWipeRequestedSigningKeys)(data))
|
|
2622
|
+
wipeRequested.add(key);
|
|
2623
|
+
}
|
|
2624
|
+
return { active: Array.from(active), wipeRequested: Array.from(wipeRequested) };
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* List a user's currently-granted device key pairs (§6.5), each with its
|
|
2628
|
+
* optional label and current remote-wipe status. Returns one entry per
|
|
2629
|
+
* signing key (revoked keys are excluded). Used by admin UIs to pick specific
|
|
2630
|
+
* devices to revoke or relabel. Grant documents are processed oldest→newest,
|
|
2631
|
+
* so the most recent label/encryption key for a signing key wins.
|
|
2632
|
+
*/
|
|
2633
|
+
async getUserKeyPairs(username) {
|
|
2634
|
+
const grants = await this.findGrantAccessDocuments(username);
|
|
2635
|
+
const bySigningKey = new Map();
|
|
2636
|
+
for (const grant of grants) {
|
|
2637
|
+
const data = grant.getData();
|
|
2638
|
+
const wipeRequestedKeys = new Set((0, grantKeys_1.extractWipeRequestedSigningKeys)(data));
|
|
2639
|
+
// Active devices only: revoked pairs are retained on the document but
|
|
2640
|
+
// excluded here, preserving the "currently-granted devices" semantics.
|
|
2641
|
+
for (const pair of (0, grantKeys_1.extractActiveKeyPairs)(data)) {
|
|
2642
|
+
bySigningKey.set(pair.signingPublicKey, {
|
|
2643
|
+
...pair,
|
|
2644
|
+
wipeRequested: wipeRequestedKeys.has(pair.signingPublicKey),
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
return Array.from(bySigningKey.values());
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* If `signingKey` is the target of an admin-requested remote wipe (§6.5),
|
|
2652
|
+
* return the id of the admin-signed grant document carrying the directive so
|
|
2653
|
+
* the sync server can serve only that document to the targeted device. Returns
|
|
2654
|
+
* null when the key is not wipe-targeted.
|
|
2655
|
+
*/
|
|
2656
|
+
async getWipeGrantDocId(signingKey) {
|
|
2657
|
+
const directoryDB = await this.getDirectoryDB();
|
|
2658
|
+
await directoryDB.syncStoreChanges();
|
|
2659
|
+
await this.updateUnifiedCache();
|
|
2660
|
+
return this.wipeKeyToGrantDocId.get(signingKey) ?? null;
|
|
2661
|
+
}
|
|
497
2662
|
/**
|
|
498
2663
|
* Check if a user has been revoked.
|
|
499
2664
|
* A user is considered revoked if they have no active (non-revoked) grant access documents.
|
|
@@ -503,64 +2668,147 @@ class BaseMindooTenantDirectory {
|
|
|
503
2668
|
*/
|
|
504
2669
|
async isUserRevoked(username) {
|
|
505
2670
|
this.logger.debug(`Checking if user is revoked: ${username}`);
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
//
|
|
509
|
-
|
|
2671
|
+
// Revocation removes the signing keys from the grant document(s) rather
|
|
2672
|
+
// than creating a separate revoke document (docs/accesscontrol.md §6.5).
|
|
2673
|
+
// A user is revoked when no grant document retains any signing key. Users
|
|
2674
|
+
// who were never granted access (no grant documents at all) are likewise
|
|
2675
|
+
// reported as having no active access.
|
|
2676
|
+
const grantDocs = await this.findGrantAccessDocuments(username);
|
|
2677
|
+
const hasActiveKey = grantDocs.some((doc) => (0, grantKeys_1.extractSigningPublicKeys)(doc.getData()).length > 0);
|
|
2678
|
+
const isRevoked = !hasActiveKey;
|
|
510
2679
|
this.logger.debug(`User ${username} revoked: ${isRevoked}`);
|
|
511
2680
|
return isRevoked;
|
|
512
2681
|
}
|
|
513
|
-
|
|
514
|
-
|
|
2682
|
+
/**
|
|
2683
|
+
* Publish an admin-signed document-history purge request (docs/accesscontrol.md
|
|
2684
|
+
* §13). Maps an unsigned {@link DocHistoryPurgeRequest} (built in-dialog or
|
|
2685
|
+
* decoded from an `mdb://doc-history-purge/...` URI) to a
|
|
2686
|
+
* {@link DocHistoryPurgeDoc} stored at the fixed id
|
|
2687
|
+
* `acl_dochistorypurge_<requestId>` (one doc per request; append-only audit
|
|
2688
|
+
* record). The doc shell is `$publicinfos`-readable so the sync server can read
|
|
2689
|
+
* the cleartext `dbId` + `docIds` routing fields and execute the purge on its
|
|
2690
|
+
* own stores; only `reason` is encrypted with the tenant `default` key.
|
|
2691
|
+
*/
|
|
2692
|
+
async publishDocHistoryPurge(request, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
2693
|
+
(0, types_3.validateDocHistoryPurge)({
|
|
2694
|
+
requestId: request.requestId,
|
|
2695
|
+
dbId: request.dbId,
|
|
2696
|
+
docIds: request.docIds,
|
|
2697
|
+
});
|
|
2698
|
+
this.logger.info(`Publishing doc history purge request ${request.requestId} for ${request.docIds.length} doc(s) in ${request.dbId}`);
|
|
2699
|
+
const reasonEncrypted = request.reason
|
|
2700
|
+
? await this.encryptToDefaultField(request.reason)
|
|
2701
|
+
: undefined;
|
|
2702
|
+
const docId = (0, types_3.aclDocHistoryPurgeDocId)(request.requestId);
|
|
2703
|
+
const requestedAt = Date.now();
|
|
515
2704
|
const baseTenant = this.tenant;
|
|
516
2705
|
const directoryDB = await this.getDirectoryDB();
|
|
517
2706
|
const adminSigningKeyPair = {
|
|
518
2707
|
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
519
2708
|
privateKey: administrationPrivateKey,
|
|
520
2709
|
};
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
data.
|
|
527
|
-
data.
|
|
528
|
-
data.
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
data.
|
|
532
|
-
}
|
|
533
|
-
|
|
2710
|
+
const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
|
|
2711
|
+
await directoryDB.changeDoc(doc, async (d) => {
|
|
2712
|
+
const data = d.getData();
|
|
2713
|
+
data.form = types_3.ACCESS_CONTROL_FORM;
|
|
2714
|
+
data.type = types_3.DOC_HISTORY_PURGE_TYPE;
|
|
2715
|
+
data.requestId = request.requestId;
|
|
2716
|
+
data.dbId = request.dbId;
|
|
2717
|
+
data.docIds = [...request.docIds];
|
|
2718
|
+
if (reasonEncrypted !== undefined) {
|
|
2719
|
+
data.reason_encrypted = reasonEncrypted;
|
|
2720
|
+
data.reason_encrypted_key = "default";
|
|
2721
|
+
}
|
|
2722
|
+
else {
|
|
2723
|
+
delete data.reason_encrypted;
|
|
2724
|
+
delete data.reason_encrypted_key;
|
|
2725
|
+
}
|
|
2726
|
+
data.preparedByPublicKey = request.preparedByPublicKey;
|
|
2727
|
+
data.requestedAt = requestedAt;
|
|
534
2728
|
}, {
|
|
535
2729
|
signingKeyPair: adminSigningKeyPair,
|
|
536
2730
|
signingKeyPassword: administrationPrivateKeyPassword,
|
|
537
2731
|
});
|
|
538
|
-
this.logger.info(`Created purge request
|
|
2732
|
+
this.logger.info(`Created purge request ${request.requestId} in ${request.dbId}`);
|
|
539
2733
|
}
|
|
540
|
-
|
|
2734
|
+
/** Parse a raw purge-request doc's data into a {@link DocHistoryPurgeView}. */
|
|
2735
|
+
async toDocHistoryPurgeView(data, purgeRequestDocId) {
|
|
2736
|
+
if (data.type !== types_3.DOC_HISTORY_PURGE_TYPE)
|
|
2737
|
+
return null;
|
|
2738
|
+
const dbId = data.dbId;
|
|
2739
|
+
if (typeof dbId !== "string")
|
|
2740
|
+
return null;
|
|
2741
|
+
const docIds = Array.isArray(data.docIds)
|
|
2742
|
+
? data.docIds.filter((id) => typeof id === "string")
|
|
2743
|
+
: [];
|
|
2744
|
+
if (docIds.length === 0)
|
|
2745
|
+
return null;
|
|
2746
|
+
const reason = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "reason_encrypted");
|
|
2747
|
+
return {
|
|
2748
|
+
requestId: typeof data.requestId === "string" ? data.requestId : purgeRequestDocId,
|
|
2749
|
+
dbId,
|
|
2750
|
+
docIds,
|
|
2751
|
+
reason,
|
|
2752
|
+
preparedByPublicKey: typeof data.preparedByPublicKey === "string" ? data.preparedByPublicKey : "",
|
|
2753
|
+
requestedAt: typeof data.requestedAt === "number" ? data.requestedAt : 0,
|
|
2754
|
+
purgeRequestDocId,
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* List all purge-request documents at the directory head as
|
|
2759
|
+
* {@link DocHistoryPurgeView}s (decrypting `reason` when the tenant default
|
|
2760
|
+
* key is held; null-tolerant otherwise).
|
|
2761
|
+
*/
|
|
2762
|
+
async listDocHistoryPurges() {
|
|
541
2763
|
const directoryDB = await this.getDirectoryDB();
|
|
542
2764
|
await directoryDB.syncStoreChanges();
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
purgeRequests.push({
|
|
554
|
-
dbId: dbId,
|
|
555
|
-
docId: docId,
|
|
556
|
-
reason: typeof reason === "string" ? reason : undefined,
|
|
557
|
-
requestedAt: typeof requestedAt === "number" ? requestedAt : Date.now(),
|
|
558
|
-
purgeRequestDocId: doc.getId(),
|
|
559
|
-
});
|
|
560
|
-
}
|
|
2765
|
+
const allIds = await directoryDB.getAllDocumentIds();
|
|
2766
|
+
const ids = allIds.filter((id) => id.startsWith(types_3.ACL_DOC_HISTORY_PURGE_PREFIX));
|
|
2767
|
+
const views = [];
|
|
2768
|
+
for (const id of ids) {
|
|
2769
|
+
let doc;
|
|
2770
|
+
try {
|
|
2771
|
+
doc = await directoryDB.getDocument(id);
|
|
2772
|
+
}
|
|
2773
|
+
catch {
|
|
2774
|
+
continue;
|
|
561
2775
|
}
|
|
2776
|
+
if (!doc)
|
|
2777
|
+
continue;
|
|
2778
|
+
const view = await this.toDocHistoryPurgeView(doc.getData(), id);
|
|
2779
|
+
if (view)
|
|
2780
|
+
views.push(view);
|
|
2781
|
+
}
|
|
2782
|
+
return views;
|
|
2783
|
+
}
|
|
2784
|
+
/** Delete a still-pending purge request document (admin-signed). */
|
|
2785
|
+
async deleteDocHistoryPurge(requestId, administrationPrivateKey, administrationPrivateKeyPassword) {
|
|
2786
|
+
const baseTenant = this.tenant;
|
|
2787
|
+
const directoryDB = await this.getDirectoryDB();
|
|
2788
|
+
const adminSigningKeyPair = {
|
|
2789
|
+
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
2790
|
+
privateKey: administrationPrivateKey,
|
|
2791
|
+
};
|
|
2792
|
+
try {
|
|
2793
|
+
await directoryDB.deleteDocument((0, types_3.aclDocHistoryPurgeDocId)(requestId), {
|
|
2794
|
+
signingKeyPair: adminSigningKeyPair,
|
|
2795
|
+
signingKeyPassword: administrationPrivateKeyPassword,
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
catch (error) {
|
|
2799
|
+
this.logger.debug(`deleteDocHistoryPurge: ${requestId} not found or already deleted: ${error}`);
|
|
562
2800
|
}
|
|
563
|
-
|
|
2801
|
+
}
|
|
2802
|
+
async getRequestedDocHistoryPurges() {
|
|
2803
|
+
const views = await this.listDocHistoryPurges();
|
|
2804
|
+
return views.map((view) => ({
|
|
2805
|
+
requestId: view.requestId,
|
|
2806
|
+
dbId: view.dbId,
|
|
2807
|
+
docIds: view.docIds,
|
|
2808
|
+
reason: view.reason ?? undefined,
|
|
2809
|
+
requestedAt: view.requestedAt,
|
|
2810
|
+
purgeRequestDocId: view.purgeRequestDocId,
|
|
2811
|
+
}));
|
|
564
2812
|
}
|
|
565
2813
|
async getTenantSettings() {
|
|
566
2814
|
const directoryDB = await this.getDirectoryDB();
|
|
@@ -678,8 +2926,9 @@ class BaseMindooTenantDirectory {
|
|
|
678
2926
|
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
679
2927
|
privateKey: administrationPrivateKey,
|
|
680
2928
|
};
|
|
681
|
-
// Compute hashes and encrypted values for all
|
|
682
|
-
|
|
2929
|
+
// Compute hashes (salted v2 for new writes) and encrypted values for all
|
|
2930
|
+
// new usernames. Reads match both v1 and v2 forms (docs/accesscontrol.md §6.5).
|
|
2931
|
+
const newMembersHashes = await Promise.all(username.map(u => this.hashUsernameForWrite(u)));
|
|
683
2932
|
const newMembersEncrypted = await Promise.all(username.map(u => this.encryptGroupMemberForTenant(u)));
|
|
684
2933
|
// Get the actual document from the database (using cached docId if available)
|
|
685
2934
|
const cachedGroup = this.groupsCache.get(normalizedGroupName);
|
|
@@ -747,8 +2996,9 @@ class BaseMindooTenantDirectory {
|
|
|
747
2996
|
publicKey: baseTenant.getAdministrationPublicKey(),
|
|
748
2997
|
privateKey: administrationPrivateKey,
|
|
749
2998
|
};
|
|
750
|
-
// Compute hashes of usernames to remove
|
|
751
|
-
|
|
2999
|
+
// Compute hashes of usernames to remove. Include both legacy (v1) and
|
|
3000
|
+
// salted (v2) forms so members stored under either scheme are removed.
|
|
3001
|
+
const hashesToRemove = new Set((await Promise.all(username.map(u => this.usernameHashCandidates(u)))).flat());
|
|
752
3002
|
// Remove users from members arrays
|
|
753
3003
|
await directoryDB.changeDoc(groupDoc, async (d) => {
|
|
754
3004
|
const data = d.getData();
|
|
@@ -819,15 +3069,79 @@ class BaseMindooTenantDirectory {
|
|
|
819
3069
|
return Array.from(result);
|
|
820
3070
|
}
|
|
821
3071
|
/**
|
|
822
|
-
* Compute
|
|
823
|
-
*
|
|
3072
|
+
* Compute the precomputed identity-hash bundle for a cleartext username
|
|
3073
|
+
* (docs/accesscontrol.md §6.5): the flattened, deduped v1+v2 hashes of every
|
|
3074
|
+
* DN-hierarchy variant from {@link generateUsernameVariants}. Stored on the
|
|
3075
|
+
* grant doc so the server can match wildcard/group read rules in hash space
|
|
3076
|
+
* without ever holding the cleartext name. Only username variant hashes are
|
|
3077
|
+
* included; group membership stays dynamic (resolved at read time).
|
|
3078
|
+
*/
|
|
3079
|
+
async computeIdentityHashes(username) {
|
|
3080
|
+
const variants = this.generateUsernameVariants(username);
|
|
3081
|
+
const hashes = new Set();
|
|
3082
|
+
for (const variant of variants) {
|
|
3083
|
+
for (const h of await this.usernameHashCandidates(variant)) {
|
|
3084
|
+
hashes.add(h);
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
return Array.from(hashes);
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Legacy (v1) username hash: `SHA-256(lower(username))`, hex-encoded.
|
|
3091
|
+
*
|
|
3092
|
+
* Retained because pre-existing directory documents (and clients that predate
|
|
3093
|
+
* the salted scheme) store this form, and lookups must keep matching them.
|
|
824
3094
|
*
|
|
825
3095
|
* @param username The username to hash
|
|
826
3096
|
* @return The hex-encoded SHA-256 hash of the lowercase username
|
|
827
3097
|
*/
|
|
828
3098
|
async hashUsername(username) {
|
|
3099
|
+
return this.sha256Hex(username.toLowerCase());
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3102
|
+
* Salted (v2) username hash: `SHA-256(tenantId + "/" + lower(username))`,
|
|
3103
|
+
* hex-encoded. The tenant id salt prevents precomputed/rainbow-table attacks
|
|
3104
|
+
* and cross-tenant hash correlation. New documents write this form
|
|
3105
|
+
* (docs/accesscontrol.md §6.5).
|
|
3106
|
+
*/
|
|
3107
|
+
async hashUsernameSalted(username) {
|
|
3108
|
+
return this.sha256Hex(`${this.tenant.getId()}/${username.toLowerCase()}`);
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* NFKC-normalized salted (v3) username hash:
|
|
3112
|
+
* `SHA-256(tenantId + "/" + lower(NFKC(username)))`, hex-encoded. NFKC
|
|
3113
|
+
* canonicalizes Unicode so visually-equivalent or differently-encoded forms
|
|
3114
|
+
* collapse to one hash, blocking homoglyph/normalization impersonation. New
|
|
3115
|
+
* documents write this form. For pure-ASCII usernames it equals v2, so
|
|
3116
|
+
* existing common cases are unaffected.
|
|
3117
|
+
*/
|
|
3118
|
+
async hashUsernameSaltedNormalized(username) {
|
|
3119
|
+
return this.sha256Hex(`${this.tenant.getId()}/${username.normalize("NFKC").toLowerCase()}`);
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* The canonical hash to WRITE on new directory documents (currently v3,
|
|
3123
|
+
* NFKC-normalized salted).
|
|
3124
|
+
*/
|
|
3125
|
+
async hashUsernameForWrite(username) {
|
|
3126
|
+
return this.hashUsernameSaltedNormalized(username);
|
|
3127
|
+
}
|
|
3128
|
+
/**
|
|
3129
|
+
* All hash forms a given username could be stored as, for MATCHING/lookup.
|
|
3130
|
+
* Returns the legacy (v1), salted (v2) and NFKC-normalized salted (v3) hashes
|
|
3131
|
+
* so that documents written under any scheme are found. Deduplicated.
|
|
3132
|
+
*/
|
|
3133
|
+
async usernameHashCandidates(username) {
|
|
3134
|
+
const [legacy, salted, saltedNormalized] = await Promise.all([
|
|
3135
|
+
this.hashUsername(username),
|
|
3136
|
+
this.hashUsernameSalted(username),
|
|
3137
|
+
this.hashUsernameSaltedNormalized(username),
|
|
3138
|
+
]);
|
|
3139
|
+
return Array.from(new Set([legacy, salted, saltedNormalized]));
|
|
3140
|
+
}
|
|
3141
|
+
/** Hex-encoded SHA-256 of a UTF-8 string. */
|
|
3142
|
+
async sha256Hex(value) {
|
|
829
3143
|
const encoder = new TextEncoder();
|
|
830
|
-
const data = encoder.encode(
|
|
3144
|
+
const data = encoder.encode(value);
|
|
831
3145
|
const subtle = this.tenant.getCryptoAdapter().getSubtle();
|
|
832
3146
|
const hashBuffer = await subtle.digest('SHA-256', data);
|
|
833
3147
|
return Array.from(new Uint8Array(hashBuffer))
|
|
@@ -853,11 +3167,18 @@ class BaseMindooTenantDirectory {
|
|
|
853
3167
|
const encrypted = await this.tenant.encryptPayload(payload, types_1.DEFAULT_TENANT_KEY_ID);
|
|
854
3168
|
return this.uint8ArrayToBase64(encrypted);
|
|
855
3169
|
}
|
|
856
|
-
|
|
3170
|
+
/**
|
|
3171
|
+
* Decrypt the `user_details_encrypted` blob on a grant document. Uses the
|
|
3172
|
+
* shared `_encrypted` / `_encrypted_key` field convention so the key id is
|
|
3173
|
+
* resolved from the optional `user_details_encrypted_key` companion field,
|
|
3174
|
+
* defaulting to the tenant default key for legacy grants that lack it.
|
|
3175
|
+
*/
|
|
3176
|
+
async decryptUserDetailsForTenant(data) {
|
|
3177
|
+
const decoded = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "user_details_encrypted");
|
|
3178
|
+
if (decoded === null) {
|
|
3179
|
+
return null;
|
|
3180
|
+
}
|
|
857
3181
|
try {
|
|
858
|
-
const encrypted = this.base64ToUint8Array(encryptedBase64);
|
|
859
|
-
const decrypted = await this.tenant.decryptPayload(encrypted, types_1.DEFAULT_TENANT_KEY_ID);
|
|
860
|
-
const decoded = new TextDecoder().decode(decrypted);
|
|
861
3182
|
const parsed = JSON.parse(decoded);
|
|
862
3183
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
863
3184
|
return null;
|
|
@@ -871,25 +3192,42 @@ class BaseMindooTenantDirectory {
|
|
|
871
3192
|
return Object.keys(details).length ? details : null;
|
|
872
3193
|
}
|
|
873
3194
|
catch (error) {
|
|
874
|
-
this.logger.debug("Could not
|
|
3195
|
+
this.logger.debug("Could not parse tenant-readable user details, treating entry as legacy or inaccessible", error);
|
|
875
3196
|
return null;
|
|
876
3197
|
}
|
|
877
3198
|
}
|
|
878
3199
|
async buildUserLookup(data) {
|
|
879
|
-
|
|
880
|
-
|
|
3200
|
+
// Prefer the array key form (key rollover / multiple devices) and fall back
|
|
3201
|
+
// to the legacy scalar fields (§6.5).
|
|
3202
|
+
const signingPublicKey = (0, grantKeys_1.extractSigningPublicKeys)(data)[0] ?? null;
|
|
3203
|
+
const encryptionPublicKey = (0, grantKeys_1.extractEncryptionPublicKeys)(data)[0] ?? null;
|
|
881
3204
|
if (!signingPublicKey || !encryptionPublicKey) {
|
|
882
3205
|
return null;
|
|
883
3206
|
}
|
|
884
3207
|
const encryptedDetails = typeof data.user_details_encrypted === "string" ? data.user_details_encrypted : null;
|
|
885
|
-
const details = encryptedDetails ? await this.decryptUserDetailsForTenant(
|
|
3208
|
+
const details = encryptedDetails ? await this.decryptUserDetailsForTenant(data) : null;
|
|
886
3209
|
const username = details?.username
|
|
887
3210
|
?? (typeof data.username_hash === "string" ? data.username_hash : "");
|
|
3211
|
+
// Surface the precomputed identity-hash bundle (§6.5). Legacy grants written
|
|
3212
|
+
// before the bundle existed have neither field; fall back to the single
|
|
3213
|
+
// username_hash (exact-match only, version 0) so the reader can flag the
|
|
3214
|
+
// grant for backfill.
|
|
3215
|
+
const usernameHash = typeof data.username_hash === "string" ? data.username_hash : null;
|
|
3216
|
+
const identityHashes = Array.isArray(data.identity_hashes)
|
|
3217
|
+
? data.identity_hashes.filter((h) => typeof h === "string" && h.length > 0)
|
|
3218
|
+
: usernameHash
|
|
3219
|
+
? [usernameHash]
|
|
3220
|
+
: [];
|
|
3221
|
+
const identityHashesV = typeof data.identity_hashes_v === "number"
|
|
3222
|
+
? data.identity_hashes_v
|
|
3223
|
+
: 0;
|
|
888
3224
|
return {
|
|
889
3225
|
username,
|
|
890
3226
|
signingPublicKey,
|
|
891
3227
|
encryptionPublicKey,
|
|
892
3228
|
details,
|
|
3229
|
+
identityHashes,
|
|
3230
|
+
identityHashesV,
|
|
893
3231
|
};
|
|
894
3232
|
}
|
|
895
3233
|
async encryptGroupMemberForTenant(plaintext) {
|
|
@@ -897,6 +3235,68 @@ class BaseMindooTenantDirectory {
|
|
|
897
3235
|
const encrypted = await this.tenant.encryptPayload(encoder.encode(plaintext), types_1.DEFAULT_TENANT_KEY_ID);
|
|
898
3236
|
return this.uint8ArrayToBase64(encrypted);
|
|
899
3237
|
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Encrypt a rule's cleartext targets (usernames + groups) with the tenant
|
|
3240
|
+
* default key so admin UIs can display who a rule targets without reversing
|
|
3241
|
+
* the salted hashes. Mirrors {@link encryptUserDetailsForTenant}: the blob is
|
|
3242
|
+
* opaque to the sync server (it never holds the tenant key) but readable by
|
|
3243
|
+
* tenant clients. Returns "" when there is nothing cleartext to store (e.g. a
|
|
3244
|
+
* rule authored from raw hashes / pseudo-tokens only).
|
|
3245
|
+
*/
|
|
3246
|
+
/**
|
|
3247
|
+
* Resolve a rule's targets (explicit `users_hashes` + named `usernames` /
|
|
3248
|
+
* `groups`) to the salted hash set evaluation matches against. Usernames are
|
|
3249
|
+
* hashed as-is; **group names are normalized (lowercased) first** so they
|
|
3250
|
+
* match the identity set, which hashes the normalized group names returned by
|
|
3251
|
+
* {@link resolveGroupsForUser} (the group cache is keyed by
|
|
3252
|
+
* {@link normalizeGroupName}). Without this, a mixed-case group target like
|
|
3253
|
+
* "Analysts" would never intersect a member's identity (hashed as "analysts").
|
|
3254
|
+
*/
|
|
3255
|
+
async resolveRuleTargetHashes(rule) {
|
|
3256
|
+
const resolvedHashes = new Set(rule.users_hashes ?? []);
|
|
3257
|
+
for (const name of rule.usernames ?? []) {
|
|
3258
|
+
resolvedHashes.add(await this.hashUsernameForWrite(name));
|
|
3259
|
+
}
|
|
3260
|
+
for (const group of rule.groups ?? []) {
|
|
3261
|
+
resolvedHashes.add(await this.hashUsernameForWrite(this.normalizeGroupName(group)));
|
|
3262
|
+
}
|
|
3263
|
+
return resolvedHashes;
|
|
3264
|
+
}
|
|
3265
|
+
async encryptRuleTargetsForTenant(targets) {
|
|
3266
|
+
const usernames = targets.usernames.filter((u) => typeof u === "string" && u.trim());
|
|
3267
|
+
const groups = targets.groups.filter((g) => typeof g === "string" && g.trim());
|
|
3268
|
+
if (usernames.length === 0 && groups.length === 0) {
|
|
3269
|
+
return "";
|
|
3270
|
+
}
|
|
3271
|
+
const payload = new TextEncoder().encode(JSON.stringify({ usernames, groups }));
|
|
3272
|
+
const encrypted = await this.tenant.encryptPayload(payload, types_1.DEFAULT_TENANT_KEY_ID);
|
|
3273
|
+
return this.uint8ArrayToBase64(encrypted);
|
|
3274
|
+
}
|
|
3275
|
+
/**
|
|
3276
|
+
* Decrypt a rule's {@link RuleTargets} display blob. Returns `null` for an
|
|
3277
|
+
* empty/unreadable blob (e.g. a client lacking the tenant default key), in
|
|
3278
|
+
* which case callers fall back to showing the raw `users_hashes`.
|
|
3279
|
+
*/
|
|
3280
|
+
async decryptRuleTargetsForTenant(encryptedBase64) {
|
|
3281
|
+
if (!encryptedBase64)
|
|
3282
|
+
return null;
|
|
3283
|
+
try {
|
|
3284
|
+
const encrypted = this.base64ToUint8Array(encryptedBase64);
|
|
3285
|
+
const decrypted = await this.tenant.decryptPayload(encrypted, types_1.DEFAULT_TENANT_KEY_ID);
|
|
3286
|
+
const parsed = JSON.parse(new TextDecoder().decode(decrypted));
|
|
3287
|
+
const usernames = Array.isArray(parsed.usernames)
|
|
3288
|
+
? parsed.usernames.filter((u) => typeof u === "string")
|
|
3289
|
+
: [];
|
|
3290
|
+
const groups = Array.isArray(parsed.groups)
|
|
3291
|
+
? parsed.groups.filter((g) => typeof g === "string")
|
|
3292
|
+
: [];
|
|
3293
|
+
return { usernames, groups };
|
|
3294
|
+
}
|
|
3295
|
+
catch (error) {
|
|
3296
|
+
this.logger.debug("Could not decrypt rule targets, falling back to hashes", error);
|
|
3297
|
+
return null;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
900
3300
|
async decryptGroupMemberForTenant(encryptedBase64) {
|
|
901
3301
|
try {
|
|
902
3302
|
const encrypted = this.base64ToUint8Array(encryptedBase64);
|
|
@@ -929,4 +3329,27 @@ class BaseMindooTenantDirectory {
|
|
|
929
3329
|
}
|
|
930
3330
|
}
|
|
931
3331
|
exports.BaseMindooTenantDirectory = BaseMindooTenantDirectory;
|
|
3332
|
+
/**
|
|
3333
|
+
* Current username-hash scheme version written on new directory documents.
|
|
3334
|
+
* v1 = unsalted `SHA-256(lower(username))` (legacy);
|
|
3335
|
+
* v2 = tenant-salted `SHA-256(tenantId + "/" + lower(username))`;
|
|
3336
|
+
* v3 = tenant-salted over the **NFKC-normalized** lowercase username,
|
|
3337
|
+
* `SHA-256(tenantId + "/" + lower(NFKC(username)))`.
|
|
3338
|
+
* v3 defends against Unicode homoglyph/normalization tricks (e.g. fullwidth
|
|
3339
|
+
* or decomposed forms that look identical but hash differently), so an
|
|
3340
|
+
* attacker cannot register a visually-equal variant of a privileged name and
|
|
3341
|
+
* have it treated as a distinct identity. See docs/accesscontrol.md §6.5
|
|
3342
|
+
* ("Username hashing"). Matching keeps v1/v2/v3 candidates so documents
|
|
3343
|
+
* written under any prior scheme still resolve (backward compatible).
|
|
3344
|
+
*/
|
|
3345
|
+
BaseMindooTenantDirectory.USERNAME_HASH_VERSION = 3;
|
|
3346
|
+
/**
|
|
3347
|
+
* Version of the identity-hash bundle (`identity_hashes`) variant-generation
|
|
3348
|
+
* algorithm (docs/accesscontrol.md §6.5). This is a SEPARATE axis from
|
|
3349
|
+
* {@link USERNAME_HASH_VERSION} (which versions only the hash scheme): it
|
|
3350
|
+
* tracks how completely {@link generateUsernameVariants} enumerated the
|
|
3351
|
+
* DN-hierarchy wildcards. Bump it whenever `generateUsernameVariants` is
|
|
3352
|
+
* extended so readers can detect stale bundles and trigger a backfill.
|
|
3353
|
+
*/
|
|
3354
|
+
BaseMindooTenantDirectory.IDENTITY_VARIANTS_VERSION = 1;
|
|
932
3355
|
//# sourceMappingURL=BaseMindooTenantDirectory.js.map
|