@atlastodo/client-core 0.1.11-rc.8 → 0.1.11

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.
@@ -18,6 +18,8 @@ export interface TokenPolicy {
18
18
  max_lifetime_days: number | null;
19
19
  /** Whether a token may write when its creator does not say. */
20
20
  write_by_default: boolean;
21
+ /** A token including the app may include no other app (the server refuses the mix). */
22
+ exclusive: boolean;
21
23
  }
22
24
  /** A bundled app manifest: its id, version, the entity kinds it syncs and its token policy. */
23
25
  export interface AppManifest {
@@ -39,7 +41,8 @@ export declare const BUNDLED_MANIFESTS: readonly AppManifest[];
39
41
  /**
40
42
  * The policy of a token for all of `apps` (bundled ids), the strictest of theirs as the server
41
43
  * takes it: an expiry required if any requires one, the shortest lifetime, and read-only by
42
- * default if any is. A client making a token reads it to ask for an expiry and write access.
44
+ * default if any is, and exclusive if any is. A client making a token reads it to ask for an expiry
45
+ * and write access, and to offer an exclusive app's token on its own.
43
46
  */
44
47
  export declare function tokenPolicyFor(apps: readonly string[]): TokenPolicy;
45
48
  /** The bundled app owning the kind with this wire name, if any. */
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export * from "./trust.js";
15
15
  export * from "./store.js";
16
16
  export * from "./opLogFile.js";
17
17
  export * from "./persistence.js";
18
+ export * from "./sealedPersistence.js";
18
19
  export * from "./attachments.js";
19
20
  export * from "./indexeddb.js";
20
21
  export * from "./expo-sqlite.js";
@@ -25,3 +26,4 @@ export * from "./password.js";
25
26
  export * from "./rootKey.js";
26
27
  export * from "./passkeys.js";
27
28
  export * from "./crypto/index.js";
29
+ export * from "./text.js";
package/dist/index.js CHANGED
@@ -1172,6 +1172,18 @@ function unwrapAppPrivateKey(wrap, appKey, userId, app) {
1172
1172
  // src/crypto/passkey.ts
1173
1173
  import { sha256 as sha2567 } from "@noble/hashes/sha2.js";
1174
1174
  import { hkdf as hkdf6 } from "@noble/hashes/hkdf.js";
1175
+
1176
+ // src/text.ts
1177
+ function trimTrailing(text, char) {
1178
+ if (char.length !== 1)
1179
+ throw new Error("trimTrailing takes one character");
1180
+ let end = text.length;
1181
+ while (end > 0 && text[end - 1] === char)
1182
+ end--;
1183
+ return end === text.length ? text : text.slice(0, end);
1184
+ }
1185
+
1186
+ // src/crypto/passkey.ts
1175
1187
  var PASSKEY_PRF_LABEL = "atlas-passkey-prf-v1";
1176
1188
  var SLOT_INFO = "atlas-passkey-slot-v1";
1177
1189
  function passkeyPrfInput() {
@@ -1181,7 +1193,7 @@ function passkeyPrfInputBase64Url() {
1181
1193
  return bytesToBase64Url(passkeyPrfInput());
1182
1194
  }
1183
1195
  function decodeBase64Url(text) {
1184
- const bytes = base64UrlToBytes(text.replace(/=+$/, ""));
1196
+ const bytes = base64UrlToBytes(trimTrailing(text, "="));
1185
1197
  if (!bytes)
1186
1198
  throw new Error("not base64url");
1187
1199
  return bytes;
@@ -1478,10 +1490,10 @@ var todo_default = {
1478
1490
  };
1479
1491
  // ../../manifests/vault.json
1480
1492
  var vault_default = {
1481
- _doc: "The Atlas Vault app manifest: a password manager, end-to-end encrypted, held to a stricter threat model than todo. Its kinds are namespaced `vault.<kind>`. Vault v1 is personal: the server never fans its ops out to another account and reads none of their fields, so every field value is encrypted (no plaintext_fields), including the item type, its folder and its timestamps. `vault.item` is one item (login, note, card, identity, ssh_key, api_key: name, typed fields, urls, notes, tags, folder, favorite, secret field history), `vault.folder` one folder, `vault.preference` one of the app's settings. `api_tokens` is the policy for API tokens that include the app, which the server enforces: an expiry is required, at most `max_lifetime_days` after creation, and a token is read-only unless its creator opts in to writes. Bump `version` whenever the kinds, their plaintext fields or the token policy change.",
1493
+ _doc: "The Atlas Vault app manifest: a password manager, end-to-end encrypted, held to a stricter threat model than todo. Its kinds are namespaced `vault.<kind>`. Vault v1 is personal: the server never fans its ops out to another account and reads none of their fields, so every field value is encrypted (no plaintext_fields), including the item type, its folder and its timestamps. `vault.item` is one item (login, note, card, identity, ssh_key, api_key: name, typed fields, urls, notes, tags, folder, favorite, secret field history), `vault.folder` one folder, `vault.preference` one of the app's settings. `api_tokens` is the policy for API tokens that include the app, which the server enforces: an expiry is required, at most `max_lifetime_days` after creation, a token is read-only unless its creator opts in to writes, and it is for the vault alone (`exclusive`: no other app's key travels with a vault secret). Bump `version` whenever the kinds, their plaintext fields or the token policy change.",
1482
1494
  app: "vault",
1483
1495
  name: "Atlas Vault",
1484
- version: 1,
1496
+ version: 2,
1485
1497
  kinds: {
1486
1498
  "vault.item": { plaintext_fields: [] },
1487
1499
  "vault.folder": { plaintext_fields: [] },
@@ -1490,7 +1502,8 @@ var vault_default = {
1490
1502
  api_tokens: {
1491
1503
  expiry_required: true,
1492
1504
  max_lifetime_days: 365,
1493
- write_by_default: false
1505
+ write_by_default: false,
1506
+ exclusive: true
1494
1507
  }
1495
1508
  };
1496
1509
 
@@ -1504,7 +1517,8 @@ function tokenPolicyFor(apps) {
1504
1517
  const policy = {
1505
1518
  expiry_required: false,
1506
1519
  max_lifetime_days: null,
1507
- write_by_default: true
1520
+ write_by_default: true,
1521
+ exclusive: false
1508
1522
  };
1509
1523
  for (const app of apps) {
1510
1524
  const p = BUNDLED_MANIFESTS.find((m) => m.app === app)?.api_tokens;
@@ -1517,6 +1531,8 @@ function tokenPolicyFor(apps) {
1517
1531
  }
1518
1532
  if (p.write_by_default === false)
1519
1533
  policy.write_by_default = false;
1534
+ if (p.exclusive)
1535
+ policy.exclusive = true;
1520
1536
  }
1521
1537
  return policy;
1522
1538
  }
@@ -4699,8 +4715,110 @@ function rowToOp(r) {
4699
4715
  value: r.value_json === null ? undefined : JSON.parse(r.value_json)
4700
4716
  };
4701
4717
  }
4702
- // src/attachments.ts
4718
+ // src/sealedPersistence.ts
4719
+ import { hkdf as hkdf7 } from "@noble/hashes/hkdf.js";
4703
4720
  import { sha256 as sha2568 } from "@noble/hashes/sha2.js";
4721
+ var LOCAL_STORE_KEY_ID = "local-v1";
4722
+ var SCOPE = { kind: "personal" };
4723
+ function deriveLocalStoreKey(appKey, userId) {
4724
+ if (appKey.length !== 32)
4725
+ throw new Error("the app key must be 32 bytes");
4726
+ return hkdf7(sha2568, appKey, utf8ToBytes(userId), utf8ToBytes("atlas-local-store-v1"), 32);
4727
+ }
4728
+
4729
+ class SealedStoreError extends Error {
4730
+ constructor(message) {
4731
+ super(message);
4732
+ this.name = "SealedStoreError";
4733
+ }
4734
+ }
4735
+ function seal(key, entity, entityId, field, value) {
4736
+ return encryptField(key, LOCAL_STORE_KEY_ID, SCOPE, { entity, entityId, field }, value);
4737
+ }
4738
+ function open(key, entity, entityId, field, stored) {
4739
+ if (!isFieldEnvelopeV2(stored) || stored.kid !== LOCAL_STORE_KEY_ID) {
4740
+ throw new SealedStoreError(`a stored value of ${entity}.${field} is not sealed`);
4741
+ }
4742
+ try {
4743
+ return decryptField(key, SCOPE, { entity, entityId, field }, stored);
4744
+ } catch {
4745
+ throw new SealedStoreError(`a stored value of ${entity}.${field} does not open with this key`);
4746
+ }
4747
+ }
4748
+ function sealOp(key, op) {
4749
+ if (op.op !== "set")
4750
+ return op;
4751
+ return { ...op, value: seal(key, op.entity, op.entityId, op.field, op.value) };
4752
+ }
4753
+ function openOp(key, op) {
4754
+ if (op.op !== "set")
4755
+ return op;
4756
+ return { ...op, value: open(key, op.entity, op.entityId, op.field, op.value) };
4757
+ }
4758
+ function sealRepairs(key, repairs) {
4759
+ return repairs.map((r) => ({ ...r, value: seal(key, r.entity, r.entityId, r.field, r.value) }));
4760
+ }
4761
+ function openRepairs(key, repairs) {
4762
+ return repairs.map((r) => ({ ...r, value: open(key, r.entity, r.entityId, r.field, r.value) }));
4763
+ }
4764
+ function sealPersistence(inner, key) {
4765
+ if (key.length !== 32)
4766
+ throw new Error("the local store key must be 32 bytes");
4767
+ const sealed = {
4768
+ load: async () => (await inner.load()).map((p) => ({ ...p, op: openOp(key, p.op) })),
4769
+ append: (op, synced) => inner.append(sealOp(key, op), synced),
4770
+ markSynced: (opIds) => inner.markSynced(opIds),
4771
+ getCursor: () => inner.getCursor(),
4772
+ setCursor: (cursor) => inner.setCursor(cursor)
4773
+ };
4774
+ if (inner.appendBatch) {
4775
+ const appendBatch = inner.appendBatch.bind(inner);
4776
+ sealed.appendBatch = (ops, synced) => appendBatch(ops.map((op) => sealOp(key, op)), synced);
4777
+ }
4778
+ if (inner.getBootstrap) {
4779
+ const getBootstrap = inner.getBootstrap.bind(inner);
4780
+ sealed.getBootstrap = async () => {
4781
+ const progress = await getBootstrap();
4782
+ if (!progress?.repairs)
4783
+ return progress;
4784
+ return { ...progress, repairs: openRepairs(key, progress.repairs) };
4785
+ };
4786
+ }
4787
+ if (inner.setBootstrap) {
4788
+ const setBootstrap = inner.setBootstrap.bind(inner);
4789
+ sealed.setBootstrap = (progress, page) => setBootstrap(progress && progress.repairs ? { ...progress, repairs: sealRepairs(key, progress.repairs) } : progress, page ? { ...page, repairs: sealRepairs(key, page.repairs) } : page);
4790
+ }
4791
+ if (inner.commitSyncBatch) {
4792
+ const commit = inner.commitSyncBatch.bind(inner);
4793
+ sealed.commitSyncBatch = (ops, cursor) => commit(ops.map((op) => sealOp(key, op)), cursor);
4794
+ }
4795
+ if (inner.restamp) {
4796
+ const restamp = inner.restamp.bind(inner);
4797
+ sealed.restamp = (ops) => restamp(ops.map((op) => sealOp(key, op)));
4798
+ }
4799
+ const passThrough = [
4800
+ "clear",
4801
+ "clearSynced",
4802
+ "compact",
4803
+ "deleteOps",
4804
+ "close",
4805
+ "loadAttachmentQueue",
4806
+ "putAttachmentUpload",
4807
+ "deleteAttachmentUpload",
4808
+ "listAttachmentQueue",
4809
+ "loadAttachmentCiphertext",
4810
+ "updateAttachmentUpload"
4811
+ ];
4812
+ for (const name of passThrough) {
4813
+ const fn = inner[name];
4814
+ if (typeof fn === "function") {
4815
+ sealed[name] = fn.bind(inner);
4816
+ }
4817
+ }
4818
+ return sealed;
4819
+ }
4820
+ // src/attachments.ts
4821
+ import { sha256 as sha2569 } from "@noble/hashes/sha2.js";
4704
4822
  var DEFAULT_MAX_BLOB_BYTES = 25 * 1024 * 1024;
4705
4823
  function maxAttachmentBytes(maxBlobBytes = DEFAULT_MAX_BLOB_BYTES) {
4706
4824
  return maxPlainSize(maxBlobBytes);
@@ -4718,7 +4836,7 @@ function generateAek() {
4718
4836
  return randomBytes(32);
4719
4837
  }
4720
4838
  function sha256Hex(bytes) {
4721
- return bytesToHex(sha2568(bytes));
4839
+ return bytesToHex(sha2569(bytes));
4722
4840
  }
4723
4841
  function bytesSource(bytes) {
4724
4842
  return {
@@ -4777,8 +4895,8 @@ async function sealSource(aek, source, opts = {}) {
4777
4895
  const expected = sealedBlobSize(total);
4778
4896
  const sink = opts.asBlob ? new BlobSink : new BufferSink(expected);
4779
4897
  const sealer = new BlobSealer(aek);
4780
- const blobHash = sha2568.create();
4781
- const plainHash = sha2568.create();
4898
+ const blobHash = sha2569.create();
4899
+ const plainHash = sha2569.create();
4782
4900
  let size = 0;
4783
4901
  const emit = (bytes) => {
4784
4902
  blobHash.update(bytes);
@@ -4841,7 +4959,7 @@ class PlainCollector {
4841
4959
  out;
4842
4960
  parts = [];
4843
4961
  length = 0;
4844
- hash = sha2568.create();
4962
+ hash = sha2569.create();
4845
4963
  constructor(blobSize) {
4846
4964
  this.out = blobSize !== null && blobSize <= MAX_PREALLOCATION ? new Uint8Array(blobSize) : null;
4847
4965
  }
@@ -4919,7 +5037,7 @@ async function fetchBlob(transport, sha, aek, plainSha) {
4919
5037
  const download = body instanceof Uint8Array ? { size: body.length, chunks: [body] } : body;
4920
5038
  const opener = new BlobOpener(aek);
4921
5039
  const plain = new PlainCollector(download.size);
4922
- const received = sha2568.create();
5040
+ const received = sha2569.create();
4923
5041
  for await (const piece of download.chunks) {
4924
5042
  received.update(piece);
4925
5043
  for (const chunk of opener.push(piece))
@@ -6690,6 +6808,7 @@ export {
6690
6808
  Keyring,
6691
6809
  LEGACY_DISCOVERY,
6692
6810
  LEGACY_KDF,
6811
+ LOCAL_STORE_KEY_ID,
6693
6812
  LocalStore,
6694
6813
  MAX_CLOCK_DRIFT_MS,
6695
6814
  MAX_COUNTER,
@@ -6716,6 +6835,7 @@ export {
6716
6835
  SNAPSHOT_PAGE_KEYS,
6717
6836
  SYNC_APPS_HEADER,
6718
6837
  SYNC_PROTOCOL,
6838
+ SealedStoreError,
6719
6839
  ServerIncompatibleError,
6720
6840
  SqlitePersistence,
6721
6841
  SyncClient,
@@ -6765,6 +6885,7 @@ export {
6765
6885
  deriveApiTokenKeys,
6766
6886
  deriveAuthAndMek,
6767
6887
  deriveAuthAndMekAsync,
6888
+ deriveLocalStoreKey,
6768
6889
  derivePasskeySlotKey,
6769
6890
  deriveRecoveryAuthKeypair,
6770
6891
  deriveRecoveryKey,
@@ -6866,6 +6987,7 @@ export {
6866
6987
  sameKeyScope,
6867
6988
  sealDelivery,
6868
6989
  sealKey,
6990
+ sealPersistence,
6869
6991
  sealRootKey,
6870
6992
  sealSource,
6871
6993
  sealedBlobSize,
@@ -6885,6 +7007,7 @@ export {
6885
7007
  toWireReport,
6886
7008
  todoAppKeyRow,
6887
7009
  tokenPolicyFor,
7010
+ trimTrailing,
6888
7011
  unpadPlaintext,
6889
7012
  unpinMemberKey,
6890
7013
  unsealKey,
@@ -0,0 +1,39 @@
1
+ import type { Persistence } from "./persistence.js";
2
+ /**
3
+ * A {@link Persistence} that keeps every op's value encrypted at rest.
4
+ *
5
+ * The local op log holds what the store merged: values already decrypted from the wire. For most
6
+ * apps that is the device's own business, but a client that locks (a password manager) must not
7
+ * leave its data readable on disk while it is locked. `sealPersistence(inner, key)` wraps any
8
+ * backend (IndexedDB, SQLite, memory) so that each `set` op's value, and each bootstrap repair's,
9
+ * is stored as a field envelope under `key` instead; loading opens them again. Everything else
10
+ * (op ids, entities, entity ids, field names, clocks, the cursor) is stored as before: the same
11
+ * metadata the server sees.
12
+ *
13
+ * The envelope is the wire's v2 field envelope (AES-256-GCM, padded, associated data naming the
14
+ * entity, field and scope) with its own key id ({@link LOCAL_STORE_KEY_ID}), so a value sealed
15
+ * here can neither be opened at another field nor pass for a wire envelope. Use a key of its own
16
+ * ({@link deriveLocalStoreKey}), not the app key itself.
17
+ *
18
+ * Every stored value must be sealed: a backend written without the wrapper, or under another key,
19
+ * fails to load with {@link SealedStoreError} rather than half-load (start over with `clear()`).
20
+ * Attachment queue rows pass through unchanged (their blob is already ciphertext; their metadata
21
+ * is not sealed): a client that needs them sealed must not queue attachments through this.
22
+ */
23
+ /** The key id of a value sealed at rest; never one the wire uses. */
24
+ export declare const LOCAL_STORE_KEY_ID = "local-v1";
25
+ /**
26
+ * The at-rest key for a user's local store, derived from an app key the client holds only while
27
+ * unlocked (HKDF-SHA256, salted with the user id, info `atlas-local-store-v1`).
28
+ */
29
+ export declare function deriveLocalStoreKey(appKey: Uint8Array, userId: string): Uint8Array;
30
+ /** A stored value that does not open under this key (another key, tampering, or never sealed). */
31
+ export declare class SealedStoreError extends Error {
32
+ constructor(message: string);
33
+ }
34
+ /**
35
+ * `inner` with every value sealed under `key` (32 bytes). The wrapper offers exactly the optional
36
+ * methods `inner` does, so a store's behaviour (batching, restamping, bootstrap resume) is the
37
+ * inner backend's. Keep a reference to `key` only while the client is unlocked.
38
+ */
39
+ export declare function sealPersistence(inner: Persistence, key: Uint8Array): Persistence;
package/dist/text.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `text` without its trailing run of `char` (one character), in linear time. A regex such as
3
+ * `/=+$/` backtracks at every `char` that is not at the end, which is quadratic on a long run of
4
+ * them followed by anything else, and these strings come from outside (a server, a config, a
5
+ * pasted secret).
6
+ */
7
+ export declare function trimTrailing(text: string, char: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlastodo/client-core",
3
- "version": "0.1.11-rc.8",
3
+ "version": "0.1.11",
4
4
  "description": "Atlas core client: hybrid logical clocks, the op log and per-field LWW merge, end-to-end encryption, the local store and the sync client",
5
5
  "license": "AGPL-3.0-only",
6
6
  "homepage": "https://github.com/atlastodo/atlas/tree/main/packages/client-core",
@@ -1,5 +1,5 @@
1
1
  {
2
- "_doc": "What the bundled app manifests (manifests/*.json) decide, asserted by packages/client-core/src/fieldPolicy.test.ts and crates/atlas-client (field_policy.rs). `fields`: whether a value of `entity`.`field` leaves the device end-to-end encrypted (`encrypted`); a kind no manifest names, and any field a manifest does not list, is encrypted. Todo keeps only the ids the server routes and checks access by in plaintext; journal and vault keep nothing, so the server sees no vault item's type, name, folder, URL, timestamp or secret. `token_policies`: the API token policy of a token for `apps`, the strictest of their manifests' `api_tokens` (`max_lifetime_days` null is no limit).",
2
+ "_doc": "What the bundled app manifests (manifests/*.json) decide, asserted by packages/client-core/src/fieldPolicy.test.ts and crates/atlas-client (field_policy.rs). `fields`: whether a value of `entity`.`field` leaves the device end-to-end encrypted (`encrypted`); a kind no manifest names, and any field a manifest does not list, is encrypted. Todo keeps only the ids the server routes and checks access by in plaintext; journal and vault keep nothing, so the server sees no vault item's type, name, folder, URL, timestamp or secret. `token_policies`: the API token policy of a token for `apps`, the strictest of their manifests' `api_tokens` (`max_lifetime_days` null is no limit; `exclusive`: the server refuses a token naming such an app with any other).",
3
3
  "fields": [
4
4
  { "entity": "task", "field": "title", "encrypted": true },
5
5
  { "entity": "task", "field": "project_id", "encrypted": false },
@@ -34,19 +34,19 @@
34
34
  "token_policies": [
35
35
  {
36
36
  "apps": ["todo"],
37
- "policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true }
37
+ "policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true, "exclusive": false }
38
38
  },
39
39
  {
40
40
  "apps": ["journal"],
41
- "policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true }
41
+ "policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true, "exclusive": false }
42
42
  },
43
43
  {
44
44
  "apps": ["vault"],
45
- "policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false }
45
+ "policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false, "exclusive": true }
46
46
  },
47
47
  {
48
48
  "apps": ["todo", "journal", "vault"],
49
- "policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false }
49
+ "policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false, "exclusive": true }
50
50
  }
51
51
  ]
52
52
  }