@3sln/trove 0.0.7 → 0.0.9
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/package.json +1 -1
- package/packages/core/src/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +141 -145
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +63 -11
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +77 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +169 -43
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// What encryption does not protect you from, said plainly and per collection.
|
|
2
|
+
//
|
|
3
|
+
// Encryption here defends the BUCKET. Anything that indexes a file sees it in the clear,
|
|
4
|
+
// because indexing is by definition reading the contents — the server decrypts before
|
|
5
|
+
// handing bytes to an indexer, and that is the whole reason full search still works on an
|
|
6
|
+
// encrypted collection. So a badge saying "encrypted" is true and, on its own, misleading.
|
|
7
|
+
//
|
|
8
|
+
// The disclosure that matters is therefore not about encryption at all. It is: which things
|
|
9
|
+
// read your files, which of them are third-party code, and where that code is allowed to
|
|
10
|
+
// send what it reads. A built-in indexer runs in this drive and talks to nobody. A plugin
|
|
11
|
+
// indexer might be pointed at an external API, and the manifest already says which one.
|
|
12
|
+
//
|
|
13
|
+
// Read off the manifest rather than written as prose. A sentence in a settings page drifts
|
|
14
|
+
// from what a plugin is actually permitted the moment either changes; a list derived from
|
|
15
|
+
// the declaration it is enforced against cannot. If it says a plugin may reach one host,
|
|
16
|
+
// that is because the plugin may reach exactly that host.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} IndexerExposure
|
|
20
|
+
* @property {string} id
|
|
21
|
+
* @property {string} name
|
|
22
|
+
* @property {'built-in'|'plugin'} source
|
|
23
|
+
* @property {string|null} pluginId
|
|
24
|
+
* @property {string[]} endpoints where this one may send what it reads; empty means nowhere
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Who reads the files in this collection, and where it can send them.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} deps
|
|
31
|
+
* @param {Array<{id: string, displayName?: string}>} deps.indexers what will run
|
|
32
|
+
* @param {Array<object>} [deps.plugins] installed plugin records, with manifests
|
|
33
|
+
* @param {(manifest: object) => string[]} [deps.endpointsOf] how to read declared egress
|
|
34
|
+
* @param {object|null} [deps.encryption] the collection's encryption config
|
|
35
|
+
*/
|
|
36
|
+
export function describeExposure({ indexers = [], plugins = [], endpointsOf = null, encryption = null } = {}) {
|
|
37
|
+
// Defaulting this to `() => []` would have every plugin report "reaches nowhere" whenever
|
|
38
|
+
// a caller forgot to wire it — an affirmative safety claim made with no evidence, which
|
|
39
|
+
// is the same mistake as calling an unresolved plugin built-in. No reader means unknown.
|
|
40
|
+
const readEndpoints = typeof endpointsOf === 'function' ? endpointsOf : null;
|
|
41
|
+
// A plugin indexer's id is a contribution URI — `trove+contrib:<domain>/<name>/<what>` —
|
|
42
|
+
// so the plugin it belongs to is derivable from the id rather than tracked separately.
|
|
43
|
+
const byId = new Map();
|
|
44
|
+
for (const p of plugins) {
|
|
45
|
+
const id = p.id || p.manifest?.name;
|
|
46
|
+
if (id) byId.set(id, p);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const rows = indexers.map((i) => {
|
|
50
|
+
// Whether something is a plugin is decided by its ID, not by whether we managed to find
|
|
51
|
+
// its install record. An indexer whose plugin we cannot resolve is still third-party
|
|
52
|
+
// code, and calling it built-in would be the most dangerous mislabel available here.
|
|
53
|
+
const contributed = String(i.id || '').startsWith('trove+contrib:');
|
|
54
|
+
const owner = contributed ? pluginOf(i.id, byId) : null;
|
|
55
|
+
return {
|
|
56
|
+
id: i.id,
|
|
57
|
+
name: i.displayName || i.id,
|
|
58
|
+
source: contributed ? 'plugin' : 'built-in',
|
|
59
|
+
pluginId: owner ? (owner.id || owner.manifest?.name || null) : null,
|
|
60
|
+
// `[]` is an affirmative claim that this reaches nowhere. Without a manifest we
|
|
61
|
+
// cannot make it, so an unresolved plugin gets `null` — unknown — and is counted
|
|
62
|
+
// among the things that might send data out rather than among the things that cannot.
|
|
63
|
+
endpoints: owner && readEndpoints ? [...new Set(readEndpoints(owner.manifest) || [])] : (contributed ? null : []),
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const reachOut = rows.filter((r) => r.endpoints === null || r.endpoints.length);
|
|
68
|
+
const unknown = rows.filter((r) => r.endpoints === null);
|
|
69
|
+
return {
|
|
70
|
+
encrypted: !!encryption?.enabled,
|
|
71
|
+
// Said explicitly, because "encrypted" without a scope is the thing people
|
|
72
|
+
// over-read. This is what the encryption is and is not.
|
|
73
|
+
protects: encryption?.enabled
|
|
74
|
+
? 'Files are encrypted before they reach the storage provider, so the bucket holds ciphertext. '
|
|
75
|
+
+ 'It is not end-to-end: this drive holds the key, and anything that indexes a file reads it in the clear.'
|
|
76
|
+
: null,
|
|
77
|
+
indexers: rows,
|
|
78
|
+
// The single fact someone should be able to see without reading a list.
|
|
79
|
+
anyEgress: reachOut.length > 0,
|
|
80
|
+
egressSummary: reachOut.length
|
|
81
|
+
? `${reachOut.length} of ${rows.length} indexers may send file contents outside this drive.`
|
|
82
|
+
+ (unknown.length
|
|
83
|
+
? ` ${unknown.length} could not be checked, because the plugin that provides it is not installed here.`
|
|
84
|
+
: '')
|
|
85
|
+
: rows.length
|
|
86
|
+
? 'No indexer on this collection may send file contents anywhere.'
|
|
87
|
+
: 'Nothing indexes this collection.',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Which installed plugin an indexer id belongs to, if any. */
|
|
92
|
+
function pluginOf(indexerId, byId) {
|
|
93
|
+
const id = String(indexerId || '');
|
|
94
|
+
if (!id.startsWith('trove+contrib:')) return null;
|
|
95
|
+
// `trove+contrib:<domain>/<name>/<contribution>` — the plugin is domain/name.
|
|
96
|
+
const path = id.slice('trove+contrib:'.length);
|
|
97
|
+
const parts = path.split('/');
|
|
98
|
+
if (parts.length < 2) return null;
|
|
99
|
+
const owner = `${parts[0]}/${parts[1]}`;
|
|
100
|
+
return byId.get(owner) || byId.get(parts[1]) || null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The key a collection's objects are encrypted with, and the fingerprint that names it.
|
|
2
|
+
//
|
|
3
|
+
// The threat model is the STORAGE HOST, and only that: the bucket holds ciphertext, so a
|
|
4
|
+
// leaked bucket credential, a public-bucket misconfiguration, or a storage vendor who is
|
|
5
|
+
// not the compute vendor learns sizes and timestamps and no content. The server holds the
|
|
6
|
+
// key — it has to, in order to hand it to a client and to decrypt for an indexer — so this
|
|
7
|
+
// is explicitly not end-to-end and does not pretend to be.
|
|
8
|
+
//
|
|
9
|
+
// Which is why the key is GENERATED rather than derived from something a user types.
|
|
10
|
+
// A passphrase would buy nothing here: the server knows the key either way, so there is no
|
|
11
|
+
// protection to gain from the user holding it, and every cost still applies — a slow KDF
|
|
12
|
+
// on every unlock, a prompt in front of every collection, a key that can be forgotten and
|
|
13
|
+
// then cannot be reset by anyone, and a re-encryption of everything whenever someone
|
|
14
|
+
// changes their password. A random 256-bit key has none of that and is stronger than any
|
|
15
|
+
// passphrase a person would choose.
|
|
16
|
+
//
|
|
17
|
+
// Access to the key is therefore an ACCESS-CONTROL question, not a knowledge one: whoever
|
|
18
|
+
// may read the collection may have the key, because they may already read its contents.
|
|
19
|
+
//
|
|
20
|
+
// If this ever becomes end-to-end, the change is confined to where the key comes from and
|
|
21
|
+
// who is allowed it. The envelope, the fingerprint, and every stored object are unchanged.
|
|
22
|
+
|
|
23
|
+
import { TroveError } from '../errors.js';
|
|
24
|
+
|
|
25
|
+
const enc = new TextEncoder();
|
|
26
|
+
|
|
27
|
+
export const KEY_BYTES = 32; // AES-256
|
|
28
|
+
export const FINGERPRINT_BYTES = 16;
|
|
29
|
+
|
|
30
|
+
/** A new collection key. Random, because there is nothing to derive it from. */
|
|
31
|
+
export function generateDataKey() {
|
|
32
|
+
return crypto.getRandomValues(new Uint8Array(KEY_BYTES));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The public name of a key.
|
|
37
|
+
*
|
|
38
|
+
* Stamped into every object and recorded on the collection, so an object can be matched to
|
|
39
|
+
* a key by whoever holds it — which is what makes a sideloaded object identifiable and
|
|
40
|
+
* what lets a key rotation tell what it has already converted.
|
|
41
|
+
*
|
|
42
|
+
* Derived through HKDF rather than being a plain hash of the key. With a random 256-bit key
|
|
43
|
+
* there is nothing to guess, so this is no longer load-bearing against an offline attack;
|
|
44
|
+
* it stays because a fingerprint should be a value derived FOR this purpose, and a bare
|
|
45
|
+
* `SHA-256(key)` is a value that might mean something somewhere else. The label makes it
|
|
46
|
+
* unambiguously this and nothing else.
|
|
47
|
+
*
|
|
48
|
+
* @param {Uint8Array} dataKey
|
|
49
|
+
* @returns {Promise<Uint8Array>} 16 bytes — far past collision risk for "which key is this"
|
|
50
|
+
*/
|
|
51
|
+
export async function fingerprint(dataKey) {
|
|
52
|
+
if (!(dataKey instanceof Uint8Array) || dataKey.length !== KEY_BYTES) {
|
|
53
|
+
throw TroveError.invalid('A data key must be 32 bytes');
|
|
54
|
+
}
|
|
55
|
+
const base = await crypto.subtle.importKey('raw', dataKey, 'HKDF', false, ['deriveBits']);
|
|
56
|
+
const bits = await crypto.subtle.deriveBits(
|
|
57
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode('trove-key-id') },
|
|
58
|
+
base,
|
|
59
|
+
FINGERPRINT_BYTES * 8,
|
|
60
|
+
);
|
|
61
|
+
return new Uint8Array(bits);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bytes as lowercase hex — how a key and a fingerprint are written down. */
|
|
65
|
+
export function toHex(b) {
|
|
66
|
+
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fromHex(hex) {
|
|
70
|
+
if (typeof hex !== 'string' || !hex.length || hex.length % 2 || /[^0-9a-f]/i.test(hex)) {
|
|
71
|
+
throw TroveError.invalid('Not hex');
|
|
72
|
+
}
|
|
73
|
+
return new Uint8Array(hex.match(/../g).map((h) => parseInt(h, 16)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Kept as the name the rest of the code already uses for a fingerprint in hex. */
|
|
77
|
+
export const fingerprintHex = toHex;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A fresh key and what the collection records about it.
|
|
81
|
+
*
|
|
82
|
+
* The key is returned separately from the config because they go to different places: the
|
|
83
|
+
* config is what any reader may see, and the key is what the server keeps.
|
|
84
|
+
*/
|
|
85
|
+
export async function newCollectionKey() {
|
|
86
|
+
const dataKey = generateDataKey();
|
|
87
|
+
return { dataKey, config: { fingerprint: toHex(await fingerprint(dataKey)) } };
|
|
88
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Which items in a collection get encrypted, and what the collection tells a client
|
|
2
|
+
// about its key.
|
|
3
|
+
//
|
|
4
|
+
// Encryption is per collection and selective within it, because "encrypt everything" is
|
|
5
|
+
// not always what someone wants and the cost is not free: an encrypted object cannot be
|
|
6
|
+
// served straight from the bucket to something that does not hold the key, and the storage
|
|
7
|
+
// host can no longer deduplicate it. So a collection says which extensions and which media
|
|
8
|
+
// types are sensitive, and the rest is stored as it always was.
|
|
9
|
+
//
|
|
10
|
+
// The rules are matched at UPLOAD time and the answer is recorded in the object itself —
|
|
11
|
+
// the envelope header — rather than re-derived later. Rules change; an object that was
|
|
12
|
+
// encrypted must stay readable as an encrypted object regardless of what the collection
|
|
13
|
+
// says today, and one that was not must not suddenly be interpreted as one.
|
|
14
|
+
|
|
15
|
+
import { TroveError } from '../errors.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a collection stores about its encryption.
|
|
19
|
+
*
|
|
20
|
+
* The fingerprint is safe to show anyone who may see the collection: it names the key
|
|
21
|
+
* without being it. The key itself is never part of this — it reaches a client through a
|
|
22
|
+
* transfer plan, which is authorized per operation.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} EncryptionConfig
|
|
25
|
+
* @property {boolean} enabled
|
|
26
|
+
* @property {string} fingerprint hex — which key this collection's objects are sealed with
|
|
27
|
+
* @property {{extensions: string[], mimeTypes: string[], all: boolean}} rules
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const normList = (v) => (Array.isArray(v) ? v : [])
|
|
31
|
+
.map((s) => String(s || '').trim().toLowerCase())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
|
|
34
|
+
/** An extension without its dot, so ".PDF", "PDF" and "pdf" are one rule. */
|
|
35
|
+
const normExt = (e) => e.replace(/^\./, '');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate and normalise what a caller asked for, against the key the server holds.
|
|
39
|
+
*
|
|
40
|
+
* The fingerprint is a separate argument rather than a field of `input` because the two
|
|
41
|
+
* come from different places and only one of them is the caller's to decide: rules are
|
|
42
|
+
* asked for, the key is minted. A collection recorded as encrypted with no fingerprint is
|
|
43
|
+
* one whose objects could never be matched to a key, so it is refused rather than repaired.
|
|
44
|
+
*
|
|
45
|
+
* @param {object|null} input what the caller asked for: `{ enabled, rules }`
|
|
46
|
+
* @param {string} [fingerprint] hex, from the collection's key
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeEncryption(input, fingerprint) {
|
|
49
|
+
if (!input || input.enabled === false) return null;
|
|
50
|
+
const fp = fingerprint ?? input.fingerprint;
|
|
51
|
+
if (!fp) throw TroveError.invalid('An encrypted collection needs a key fingerprint');
|
|
52
|
+
if (!/^[0-9a-f]{32}$/.test(String(fp))) {
|
|
53
|
+
throw TroveError.invalid('Not a key fingerprint');
|
|
54
|
+
}
|
|
55
|
+
const r = input.rules || {};
|
|
56
|
+
const out = {
|
|
57
|
+
enabled: true,
|
|
58
|
+
fingerprint: String(fp),
|
|
59
|
+
rules: {
|
|
60
|
+
all: !!r.all,
|
|
61
|
+
extensions: [...new Set(normList(r.extensions).map(normExt))],
|
|
62
|
+
mimeTypes: [...new Set(normList(r.mimeTypes))],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
if (!out.rules.all && !out.rules.extensions.length && !out.rules.mimeTypes.length) {
|
|
66
|
+
// Enabling encryption and matching nothing is almost certainly a mistake, and a silent
|
|
67
|
+
// one: every upload would be stored in the clear on a collection labelled encrypted.
|
|
68
|
+
throw TroveError.invalid(
|
|
69
|
+
'This collection is set to encrypt, but no file would match. Choose "all files", or name some extensions or media types.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Should this item be encrypted?
|
|
77
|
+
*
|
|
78
|
+
* A media type match is by full type or by its leading part, so `image` covers
|
|
79
|
+
* `image/png` without listing every format — which is how someone actually thinks about
|
|
80
|
+
* "encrypt my photos".
|
|
81
|
+
*/
|
|
82
|
+
export function shouldEncrypt(encryption, { name = '', contentType = '' } = {}) {
|
|
83
|
+
if (!encryption?.enabled) return false;
|
|
84
|
+
const { rules } = encryption;
|
|
85
|
+
if (rules.all) return true;
|
|
86
|
+
|
|
87
|
+
const ext = normExt((String(name).match(/\.[^./\\]+$/) || [''])[0].toLowerCase());
|
|
88
|
+
if (ext && rules.extensions.includes(ext)) return true;
|
|
89
|
+
|
|
90
|
+
const type = String(contentType || '').toLowerCase().split(';')[0].trim();
|
|
91
|
+
if (!type) return false;
|
|
92
|
+
if (rules.mimeTypes.includes(type)) return true;
|
|
93
|
+
const [top] = type.split('/');
|
|
94
|
+
return !!top && rules.mimeTypes.includes(top);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a client is told about a collection's encryption.
|
|
99
|
+
*
|
|
100
|
+
* Enough to know that objects here are sealed and which key seals them; never the key.
|
|
101
|
+
* There is no "locked" state and nothing to prompt for — a client that may read the
|
|
102
|
+
* collection is handed the key with the transfer plan, because being allowed to read the
|
|
103
|
+
* contents and being allowed to decrypt them are the same permission.
|
|
104
|
+
*/
|
|
105
|
+
export function describeEncryption(encryption) {
|
|
106
|
+
if (!encryption?.enabled) return null;
|
|
107
|
+
return {
|
|
108
|
+
enabled: true,
|
|
109
|
+
fingerprint: encryption.fingerprint,
|
|
110
|
+
rules: { ...encryption.rules },
|
|
111
|
+
};
|
|
112
|
+
}
|