@telecrypt-io/storage 0.9.2 → 0.9.4

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.md CHANGED
@@ -12,6 +12,9 @@ unmerged Matrix proposal.
12
12
  npm install @telecrypt-io/storage
13
13
  ```
14
14
 
15
+ The SDK supports Node.js `24.21.0` as its exact engine version. Its release tooling uses the
16
+ bundled npm `11.19.0`.
17
+
15
18
  ## Browser quick start
16
19
 
17
20
  The example below starts with an authenticated OIDC session. Obtain `userId`, `accessToken`, and
@@ -170,6 +170,7 @@ export declare class TeleCryptIOStorage {
170
170
  private getStateEvent;
171
171
  private getTreeOwnerId;
172
172
  private sendEncryptedMetadataMessage;
173
+ private requireEncryptedStorageRoom;
173
174
  private readEncryptedMetadataMessage;
174
175
  private writeTreeName;
175
176
  private getTreeMetadataEvent;
@@ -1,4 +1,5 @@
1
1
  import { ClientEvent, createClient, EventType, JoinRule, MatrixEvent, MatrixError, MsgType, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
2
+ import { EventShieldReason, OnlySignedDevicesIsolationMode, } from "matrix-js-sdk/lib/crypto-api/index.js";
2
3
  import { Method } from "matrix-js-sdk/lib/http-api/method.js";
3
4
  import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
4
5
  import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
@@ -7,7 +8,7 @@ import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError
7
8
  import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_BYTES, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
8
9
  import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseBody, ResponseBodyReadError, } from "./core/http.js";
9
10
  import { sanitizeDiagnosticText } from "./core/oidc.js";
10
- import { validateName } from "./core/validation.js";
11
+ import { isStorageTreeRoom, isStorageTreeState, STORAGE_ROOM_ENCRYPTION_ALGORITHM, validateName, } from "./core/validation.js";
11
12
  import { ConditionTimeoutError, waitForCondition } from "./core/poll.js";
12
13
  import { isTreeDeleted } from "./deletion-markers.js";
13
14
  import { DecryptionKeySafe } from "./key-safe.js";
@@ -484,9 +485,16 @@ export class TeleCryptIOStorage {
484
485
  return ownerId;
485
486
  }
486
487
  async sendEncryptedMetadataMessage(roomId, content) {
488
+ await this.refreshRoomState(roomId);
489
+ this.requireEncryptedStorageRoom(roomId);
487
490
  const response = await this.client.sendMessage(roomId, content);
488
491
  return validateMatrixEventId(response?.event_id, "storage metadata response event ID");
489
492
  }
493
+ requireEncryptedStorageRoom(roomId) {
494
+ if (!isStorageTreeRoom(this.client.getRoom(roomId))) {
495
+ throw new StorageError("storage room encryption is unavailable or unsupported");
496
+ }
497
+ }
490
498
  async readEncryptedMetadataMessage(roomId, eventId, allowRedacted = false) {
491
499
  const rawEvent = await this.client.fetchRoomEvent(roomId, eventId);
492
500
  const event = this.client.getEventMapper()(rawEvent);
@@ -507,6 +515,13 @@ export class TeleCryptIOStorage {
507
515
  event.getType() !== EventType.RoomMessage) {
508
516
  throw new StorageError("encrypted storage metadata is invalid or unavailable");
509
517
  }
518
+ const crypto = this.client.getCrypto();
519
+ if (!crypto)
520
+ throw new StorageError("encryption is not initialized");
521
+ const encryptionInfo = await crypto.getEncryptionInfoForEvent(event);
522
+ if (!encryptionInfo || encryptionInfo.shieldReason === EventShieldReason.MISMATCHED_SENDER) {
523
+ throw new UndecryptableFileError();
524
+ }
510
525
  return event;
511
526
  }
512
527
  async writeTreeName(roomId, name) {
@@ -591,7 +606,7 @@ export class TeleCryptIOStorage {
591
606
  }
592
607
  const wire = event.getWireContent();
593
608
  if (event.isRedacted() || event.isDecryptionFailure() ||
594
- wire.algorithm !== "m.megolm.v1.aes-sha2" || typeof wire.session_id !== "string" || !wire.session_id) {
609
+ wire.algorithm !== STORAGE_ROOM_ENCRYPTION_ALGORITHM || typeof wire.session_id !== "string" || !wire.session_id) {
595
610
  throw new StorageError("encrypted storage session is unavailable");
596
611
  }
597
612
  sessionId = wire.session_id;
@@ -745,6 +760,7 @@ export class TeleCryptIOStorage {
745
760
  await this.refreshRoomState(treeId, { signal });
746
761
  if (signal?.aborted)
747
762
  throw new StorageError("operation cancelled");
763
+ this.requireEncryptedStorageRoom(treeId);
748
764
  const bytes = encryptedContents instanceof Uint8Array
749
765
  ? encryptedContents
750
766
  : new Uint8Array(encryptedContents);
@@ -916,8 +932,8 @@ export class TeleCryptIOStorage {
916
932
  const event = (type, key = "") => state.find((item) => item.type === type && item.state_key === key);
917
933
  const create = event(EventType.RoomCreate);
918
934
  const purpose = event(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
919
- if (create?.content?.[RoomCreateTypeField] !== RoomType.Space ||
920
- purpose?.content?.[UNSTABLE_MSC3088_ENABLED.name] !== true)
935
+ const encryption = event(EventType.RoomEncryption);
936
+ if (!isStorageTreeState(create?.content, purpose?.content, encryption?.content))
921
937
  return false;
922
938
  const userId = this.client.getUserId();
923
939
  if (!userId || create?.sender !== userId ||
@@ -1023,6 +1039,10 @@ export class TeleCryptIOStorage {
1023
1039
  useIndexedDB: persistent,
1024
1040
  cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
1025
1041
  }), opts.initTimeoutMs ?? 60000, "crypto init", opts.signal);
1042
+ const crypto = client.getCrypto();
1043
+ if (!crypto)
1044
+ throw new StorageError("encryption is not initialized");
1045
+ crypto.setDeviceIsolationMode(new OnlySignedDevicesIsolationMode());
1026
1046
  progress("Encryption ready — opening secure store…");
1027
1047
  storage = new TeleCryptIOStorage(client, opts, opts);
1028
1048
  if (opts.startClient === false) {
@@ -1559,7 +1579,7 @@ export class TeleCryptIOStorage {
1559
1579
  {
1560
1580
  type: EventType.RoomEncryption,
1561
1581
  state_key: "",
1562
- content: { algorithm: "m.megolm.v1.aes-sha2" },
1582
+ content: { algorithm: STORAGE_ROOM_ENCRYPTION_ALGORITHM },
1563
1583
  },
1564
1584
  ],
1565
1585
  };
@@ -1572,8 +1592,9 @@ export class TeleCryptIOStorage {
1572
1592
  throw new StorageError("operation cancelled");
1573
1593
  try {
1574
1594
  const tree = this.client.unstableGetFileTreeSpace(roomId);
1575
- if (tree)
1595
+ if (tree && isStorageTreeRoom(this.client.getRoom(roomId))) {
1576
1596
  return this.decorateTreeSpace(tree);
1597
+ }
1577
1598
  }
1578
1599
  catch (error) {
1579
1600
  lastError = error;
@@ -1613,13 +1634,29 @@ export class TeleCryptIOStorage {
1613
1634
  return tree;
1614
1635
  this.decoratedTreeSpaces.add(tree);
1615
1636
  const originalGetDirectories = tree.getDirectories.bind(tree);
1637
+ const originalGetDirectory = typeof tree.getDirectory === "function"
1638
+ ? tree.getDirectory.bind(tree)
1639
+ : undefined;
1616
1640
  const originalListFiles = typeof tree.listFiles === "function"
1617
1641
  ? tree.listFiles.bind(tree)
1618
1642
  : () => [];
1619
1643
  const originalGetFile = typeof tree.getFile === "function"
1620
1644
  ? tree.getFile.bind(tree)
1621
1645
  : () => null;
1622
- tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
1646
+ tree.getDirectories = () => originalGetDirectories()
1647
+ .filter((child) => !isTreeDeleted(this.client, child.id) &&
1648
+ isStorageTreeRoom(this.client.getRoom(child.id)))
1649
+ .map((child) => this.decorateTreeSpace(child));
1650
+ if (originalGetDirectory) {
1651
+ tree.getDirectory = (roomId) => {
1652
+ const child = originalGetDirectory(roomId);
1653
+ return child &&
1654
+ !isTreeDeleted(this.client, child.id) &&
1655
+ isStorageTreeRoom(this.client.getRoom(child.id))
1656
+ ? this.decorateTreeSpace(child)
1657
+ : undefined;
1658
+ };
1659
+ }
1623
1660
  tree.createDirectory = (name) => this.createSubtree(tree, name);
1624
1661
  tree.setName = (name) => this.writeTreeName(tree.id, name);
1625
1662
  tree.listFiles = () => originalListFiles().map((branch) => this.decorateFileBranch(tree.id, branch));
@@ -1782,14 +1819,16 @@ export class TeleCryptIOStorage {
1782
1819
  throw new StorageError("operation cancelled");
1783
1820
  if (isTreeDeleted(this.client, room.roomId))
1784
1821
  continue;
1785
- const tree = this.client.unstableGetFileTreeSpace(room.roomId);
1822
+ let tree = this.getTree(room.roomId);
1786
1823
  if (tree) {
1787
1824
  // Existing owner sessions repair the persisted permissions on load;
1788
1825
  // readers never attempt this owner-only mutation.
1789
1826
  if (this.getStateEvent(room.roomId, EventType.RoomCreate, "")?.getSender() === this.client.getUserId()) {
1790
1827
  await this.refreshRoomState(room.roomId, { signal });
1828
+ tree = this.getTree(room.roomId);
1791
1829
  }
1792
- trees.push(this.decorateTreeSpace(tree));
1830
+ if (tree)
1831
+ trees.push(tree);
1793
1832
  }
1794
1833
  }
1795
1834
  return trees;
@@ -1943,6 +1982,8 @@ export class TeleCryptIOStorage {
1943
1982
  getTree(roomId) {
1944
1983
  if (isTreeDeleted(this.client, roomId))
1945
1984
  return null;
1985
+ if (!isStorageTreeRoom(this.client.getRoom(roomId)))
1986
+ return null;
1946
1987
  const tree = this.client.unstableGetFileTreeSpace(roomId);
1947
1988
  return tree ? this.decorateTreeSpace(tree) : null;
1948
1989
  }
@@ -30,9 +30,9 @@ export interface OidcRegistrationClientMetadata {
30
30
  }
31
31
  /** The issuer-bound endpoints accepted by the token refresh adapter. */
32
32
  export type OidcTokenEndpointMetadata = Pick<OidcClientConfig, "issuer" | "token_endpoint" | "revocation_endpoint">;
33
- /** Keep provider diagnostics useful without allowing credentials, customer
34
- * identifiers, or control characters from an untrusted response to cross the
35
- * SDK boundary. */
33
+ /** Redact recognized secret fields and identifiers in provider diagnostics.
34
+ * Structured JSON uses decoded property names at every depth; the text fallback
35
+ * is limited to patterns recognized in unstructured input. */
36
36
  export declare function sanitizeDiagnosticText(value: string): string;
37
37
  /** Validates an endpoint against the configured homeserver and optional issuer path. */
38
38
  export declare function assertOidcEndpoint(value: unknown, trustedHomeserver: string, name: string, issuer?: URL, allowQuery?: boolean): string;
package/dist/core/oidc.js CHANGED
@@ -64,10 +64,8 @@ const DIAGNOSTIC_SECRET_FIELD = /(?:access[_-]?token|refresh[_-]?token|id[_-]?to
64
64
  const DIAGNOSTIC_EMAIL = /[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9-]+(?:\.[A-Z0-9-]+)+/giu;
65
65
  const DIAGNOSTIC_MXID = /@[A-Z0-9._=+\-/]+:[A-Z0-9.-]+/giu;
66
66
  const DIAGNOSTIC_ULID = /\b[0-9A-HJKMNP-TV-Z]{26}\b/giu;
67
- /** Keep provider diagnostics useful without allowing credentials, customer
68
- * identifiers, or control characters from an untrusted response to cross the
69
- * SDK boundary. */
70
- export function sanitizeDiagnosticText(value) {
67
+ /** Existing fallback for plain or malformed provider diagnostics. */
68
+ function sanitizeUnstructuredDiagnosticText(value) {
71
69
  const quotedValue = `"(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*'|[^\\s,};\\]]+`;
72
70
  return value
73
71
  .replace(/(Bearer\s+)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s"',}]+)/giu, "$1<redacted>")
@@ -81,6 +79,21 @@ export function sanitizeDiagnosticText(value) {
81
79
  .replace(/[\r\n\u0000-\u001f\u007f-\u009f\u2028\u2029]/gu, " ")
82
80
  .trim();
83
81
  }
82
+ /** Redact recognized secret fields and identifiers in provider diagnostics.
83
+ * Structured JSON uses decoded property names at every depth; the text fallback
84
+ * is limited to patterns recognized in unstructured input. */
85
+ export function sanitizeDiagnosticText(value) {
86
+ try {
87
+ return JSON.stringify(JSON.parse(value), (key, item) => {
88
+ if (DIAGNOSTIC_SECRET_FIELD.test(key))
89
+ return "<redacted>";
90
+ return typeof item === "string" ? sanitizeUnstructuredDiagnosticText(item) : item;
91
+ });
92
+ }
93
+ catch {
94
+ return sanitizeUnstructuredDiagnosticText(value);
95
+ }
96
+ }
84
97
  class OidcResponseError extends Error {
85
98
  status;
86
99
  constructor(operation, status, body, cause) {
@@ -11,12 +11,12 @@
11
11
  * platform-specific and stays with the caller.
12
12
  */
13
13
  import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
14
- import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
14
+ import { EventType, MatrixError, } from "matrix-js-sdk";
15
15
  import { deleteOwnedMedia } from "../media.js";
16
16
  import { validateMatrixEventId } from "./constants.js";
17
17
  import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
18
18
  import { ConditionTimeoutError, waitForCondition } from "./poll.js";
19
- import { validateName } from "./validation.js";
19
+ import { hasStorageTreeMarkersRoom, isStorageTreeRoom, validateName } from "./validation.js";
20
20
  import { prepareReaderHistorySharing } from "../sharing.js";
21
21
  import { isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
22
22
  /**
@@ -660,14 +660,6 @@ function hasActiveSpaceParent(room) {
660
660
  const events = result == null ? [] : Array.isArray(result) ? result : [result];
661
661
  return events.some((event) => isActiveRelationEvent(event));
662
662
  }
663
- function isReviewedStorageTreeRoom(room) {
664
- const createEvent = room?.currentState?.getStateEvents?.(EventType.RoomCreate, "");
665
- const createContent = createEvent?.getContent?.();
666
- const purposeEvent = room?.currentState?.getStateEvents?.(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
667
- const purposeContent = purposeEvent?.getContent?.();
668
- return (createContent?.[RoomCreateTypeField] === RoomType.Space &&
669
- purposeContent?.[UNSTABLE_MSC3088_ENABLED.name] === true);
670
- }
671
663
  /** Rooms where this account is invited and the room looks like a file tree. */
672
664
  export async function listPendingInvites(storage, options) {
673
665
  await storage.keySafe.requireReady({ signal: options?.signal });
@@ -695,7 +687,7 @@ export async function listPendingInvites(storage, options) {
695
687
  }
696
688
  // Invite state may not have MSC3089 tree metadata yet — accept rooms whose
697
689
  // create event marks them as a file tree space.
698
- if (isReviewedStorageTreeRoom(room)) {
690
+ if (isStorageTreeRoom(room)) {
699
691
  invites.push({
700
692
  id: room.roomId,
701
693
  name: roomDisplayName(storage, room.roomId),
@@ -734,7 +726,7 @@ export async function declineInvite(storage, vaultId, options) {
734
726
  // requiring a joined TreeSpace object.
735
727
  if (!currentRoom ||
736
728
  hasActiveSpaceParent(currentRoom) ||
737
- !isReviewedStorageTreeRoom(currentRoom)) {
729
+ !hasStorageTreeMarkersRoom(currentRoom)) {
738
730
  throw new StorageError("decline failed");
739
731
  }
740
732
  let membership;
@@ -1,3 +1,18 @@
1
1
  /** Shared name contract used by SDK inputs and remote projections. */
2
2
  export declare function isValidName(value: unknown): value is string;
3
3
  export declare function validateName(value: unknown, label: string): asserts value is string;
4
+ export declare const STORAGE_ROOM_ENCRYPTION_ALGORITHM = "m.megolm.v1.aes-sha2";
5
+ interface StorageTreeRoomLike {
6
+ currentState?: {
7
+ getStateEvents?: (eventType: string, stateKey?: string) => unknown;
8
+ };
9
+ }
10
+ /** The shared Storage-room contract for both synced rooms and refreshed state. */
11
+ export declare function isStorageTreeState(createContent: unknown, purposeContent: unknown, encryptionContent: unknown): boolean;
12
+ /** The room markers needed for explicit invite cleanup before encryption is available. */
13
+ export declare function hasStorageTreeMarkers(createContent: unknown, purposeContent: unknown): boolean;
14
+ /** Checks the existing Storage markers and Megolm room encryption together. */
15
+ export declare function isStorageTreeRoom(room: StorageTreeRoomLike | null | undefined): boolean;
16
+ /** Checks only the room markers for explicit cleanup paths such as declining an invite. */
17
+ export declare function hasStorageTreeMarkersRoom(room: StorageTreeRoomLike | null | undefined): boolean;
18
+ export {};
@@ -1,3 +1,4 @@
1
+ import { EventType, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
1
2
  /** Shared name contract used by SDK inputs and remote projections. */
2
3
  export function isValidName(value) {
3
4
  return (typeof value === "string" &&
@@ -9,3 +10,47 @@ export function validateName(value, label) {
9
10
  throw new Error(`invalid ${label}`);
10
11
  }
11
12
  }
13
+ export const STORAGE_ROOM_ENCRYPTION_ALGORITHM = "m.megolm.v1.aes-sha2";
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ /** The shared Storage-room contract for both synced rooms and refreshed state. */
18
+ export function isStorageTreeState(createContent, purposeContent, encryptionContent) {
19
+ return hasStorageTreeMarkers(createContent, purposeContent) &&
20
+ isRecord(encryptionContent) &&
21
+ encryptionContent.algorithm === STORAGE_ROOM_ENCRYPTION_ALGORITHM;
22
+ }
23
+ /** The room markers needed for explicit invite cleanup before encryption is available. */
24
+ export function hasStorageTreeMarkers(createContent, purposeContent) {
25
+ return (isRecord(createContent) &&
26
+ createContent[RoomCreateTypeField] === RoomType.Space &&
27
+ isRecord(purposeContent) &&
28
+ purposeContent[UNSTABLE_MSC3088_ENABLED.name] === true);
29
+ }
30
+ function getStorageTreeState(room) {
31
+ const getContent = (eventType, stateKey) => {
32
+ const raw = room?.currentState?.getStateEvents?.(eventType, stateKey);
33
+ const event = Array.isArray(raw) ? raw[0] : raw;
34
+ if (typeof event !== "object" || event === null)
35
+ return undefined;
36
+ const getEventContent = event.getContent;
37
+ return typeof getEventContent === "function"
38
+ ? getEventContent.call(event)
39
+ : undefined;
40
+ };
41
+ return {
42
+ createContent: getContent(EventType.RoomCreate, ""),
43
+ purposeContent: getContent(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name),
44
+ encryptionContent: getContent(EventType.RoomEncryption, ""),
45
+ };
46
+ }
47
+ /** Checks the existing Storage markers and Megolm room encryption together. */
48
+ export function isStorageTreeRoom(room) {
49
+ const state = getStorageTreeState(room);
50
+ return isStorageTreeState(state.createContent, state.purposeContent, state.encryptionContent);
51
+ }
52
+ /** Checks only the room markers for explicit cleanup paths such as declining an invite. */
53
+ export function hasStorageTreeMarkersRoom(room) {
54
+ const state = getStorageTreeState(room);
55
+ return hasStorageTreeMarkers(state.createContent, state.purposeContent);
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",
@@ -22,7 +22,7 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "engines": {
25
- "node": "24.20.0"
25
+ "node": "24.21.0"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"
@@ -48,7 +48,7 @@
48
48
  "eslint": "10.9.0",
49
49
  "fake-indexeddb": "6.2.5",
50
50
  "typescript": "5.9.3",
51
- "vitest": "4.1.11"
51
+ "vitest": "4.1.12"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",