@atlastodo/client-core 0.1.11-rc.7 → 0.1.11-rc.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/dist/crypto/rootKey.d.ts +2 -0
- package/dist/fieldPolicy.d.ts +31 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +171 -8
- package/dist/sealedPersistence.d.ts +39 -0
- package/dist/types.d.ts +27 -1
- package/package.json +2 -1
- package/test-vectors/api_token_vectors.json +14 -0
- package/test-vectors/field_policy_vectors.json +52 -0
package/dist/crypto/rootKey.d.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
/** The app whose key is the account's original DEK. */
|
|
18
18
|
export declare const TODO_APP = "todo";
|
|
19
|
+
/** Atlas Journal: its key and identity are a row under the root (`enableApp`). */
|
|
20
|
+
export declare const JOURNAL_APP = "journal";
|
|
19
21
|
/** A key wrapped under another (root slot, app key, app identity). */
|
|
20
22
|
export interface RootKeyWrap {
|
|
21
23
|
v: 1;
|
package/dist/fieldPolicy.d.ts
CHANGED
|
@@ -7,7 +7,21 @@
|
|
|
7
7
|
* reads against.
|
|
8
8
|
*/
|
|
9
9
|
import type { EntityKind } from "./types.js";
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* What an app asks of the API tokens that include it (`api_tokens` in its manifest), which the
|
|
12
|
+
* server enforces. Absent keys are the defaults: no expiry needed, writes allowed.
|
|
13
|
+
*/
|
|
14
|
+
export interface TokenPolicy {
|
|
15
|
+
/** A token must name an expiry. */
|
|
16
|
+
expiry_required: boolean;
|
|
17
|
+
/** The latest expiry a token may name, in days after it is made; null is no limit. */
|
|
18
|
+
max_lifetime_days: number | null;
|
|
19
|
+
/** Whether a token may write when its creator does not say. */
|
|
20
|
+
write_by_default: boolean;
|
|
21
|
+
/** A token including the app may include no other app (the server refuses the mix). */
|
|
22
|
+
exclusive: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** A bundled app manifest: its id, version, the entity kinds it syncs and its token policy. */
|
|
11
25
|
export interface AppManifest {
|
|
12
26
|
app: string;
|
|
13
27
|
name: string;
|
|
@@ -15,14 +29,28 @@ export interface AppManifest {
|
|
|
15
29
|
kinds: Record<string, {
|
|
16
30
|
plaintext_fields: readonly string[] | "all";
|
|
17
31
|
}>;
|
|
32
|
+
api_tokens?: Partial<TokenPolicy>;
|
|
18
33
|
}
|
|
19
34
|
/**
|
|
20
35
|
* The manifests bundled into this client, as the server bundles them. `EntityKind` (and so
|
|
21
|
-
* {@link PLAINTEXT_FIELDS})
|
|
22
|
-
*
|
|
36
|
+
* {@link PLAINTEXT_FIELDS}) holds every bundled app's kinds, as `atlas_core::EntityKind` does;
|
|
37
|
+
* which of them a client syncs is its session's app scope. Journal's and the vault's kinds
|
|
38
|
+
* encrypt every field.
|
|
23
39
|
*/
|
|
24
40
|
export declare const BUNDLED_MANIFESTS: readonly AppManifest[];
|
|
41
|
+
/**
|
|
42
|
+
* The policy of a token for all of `apps` (bundled ids), the strictest of theirs as the server
|
|
43
|
+
* takes it: an expiry required if any requires one, the shortest lifetime, and read-only by
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
export declare function tokenPolicyFor(apps: readonly string[]): TokenPolicy;
|
|
25
48
|
/** The bundled app owning the kind with this wire name, if any. */
|
|
26
49
|
export declare function appOfKind(kind: string): AppManifest | undefined;
|
|
27
50
|
export declare const PLAINTEXT_FIELDS: Readonly<Record<EntityKind, readonly string[] | "all">>;
|
|
51
|
+
/**
|
|
52
|
+
* Whether a value of `entity`.`field` is end-to-end encrypted, for any wire kind: a kind no
|
|
53
|
+
* bundled manifest names encrypts everything.
|
|
54
|
+
*/
|
|
55
|
+
export declare function shouldEncryptKindField(entity: string, field: string): boolean;
|
|
28
56
|
export declare function shouldEncryptField(entity: EntityKind, field: string): boolean;
|
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";
|
package/dist/index.js
CHANGED
|
@@ -1099,6 +1099,7 @@ import { x25519 as x255193 } from "@noble/curves/ed25519.js";
|
|
|
1099
1099
|
import { sha256 as sha2566 } from "@noble/hashes/sha2.js";
|
|
1100
1100
|
import { hkdf as hkdf5 } from "@noble/hashes/hkdf.js";
|
|
1101
1101
|
var TODO_APP = "todo";
|
|
1102
|
+
var JOURNAL_APP = "journal";
|
|
1102
1103
|
var KEY_BYTES = 32;
|
|
1103
1104
|
var json = (parts) => utf8ToBytes(JSON.stringify(parts));
|
|
1104
1105
|
var rootAad = (userId, slot) => json(["atlas-root-key", 1, userId.toLowerCase(), slot]);
|
|
@@ -1475,12 +1476,54 @@ var todo_default = {
|
|
|
1475
1476
|
}
|
|
1476
1477
|
}
|
|
1477
1478
|
};
|
|
1479
|
+
// ../../manifests/vault.json
|
|
1480
|
+
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, 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
|
+
app: "vault",
|
|
1483
|
+
name: "Atlas Vault",
|
|
1484
|
+
version: 2,
|
|
1485
|
+
kinds: {
|
|
1486
|
+
"vault.item": { plaintext_fields: [] },
|
|
1487
|
+
"vault.folder": { plaintext_fields: [] },
|
|
1488
|
+
"vault.preference": { plaintext_fields: [] }
|
|
1489
|
+
},
|
|
1490
|
+
api_tokens: {
|
|
1491
|
+
expiry_required: true,
|
|
1492
|
+
max_lifetime_days: 365,
|
|
1493
|
+
write_by_default: false,
|
|
1494
|
+
exclusive: true
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1478
1497
|
|
|
1479
1498
|
// src/fieldPolicy.ts
|
|
1480
1499
|
var BUNDLED_MANIFESTS = Object.freeze([
|
|
1481
1500
|
todo_default,
|
|
1482
|
-
journal_default
|
|
1501
|
+
journal_default,
|
|
1502
|
+
vault_default
|
|
1483
1503
|
]);
|
|
1504
|
+
function tokenPolicyFor(apps) {
|
|
1505
|
+
const policy = {
|
|
1506
|
+
expiry_required: false,
|
|
1507
|
+
max_lifetime_days: null,
|
|
1508
|
+
write_by_default: true,
|
|
1509
|
+
exclusive: false
|
|
1510
|
+
};
|
|
1511
|
+
for (const app of apps) {
|
|
1512
|
+
const p = BUNDLED_MANIFESTS.find((m) => m.app === app)?.api_tokens;
|
|
1513
|
+
if (!p)
|
|
1514
|
+
continue;
|
|
1515
|
+
if (p.expiry_required)
|
|
1516
|
+
policy.expiry_required = true;
|
|
1517
|
+
if (p.max_lifetime_days != null) {
|
|
1518
|
+
policy.max_lifetime_days = policy.max_lifetime_days == null ? p.max_lifetime_days : Math.min(policy.max_lifetime_days, p.max_lifetime_days);
|
|
1519
|
+
}
|
|
1520
|
+
if (p.write_by_default === false)
|
|
1521
|
+
policy.write_by_default = false;
|
|
1522
|
+
if (p.exclusive)
|
|
1523
|
+
policy.exclusive = true;
|
|
1524
|
+
}
|
|
1525
|
+
return policy;
|
|
1526
|
+
}
|
|
1484
1527
|
function appOfKind(kind) {
|
|
1485
1528
|
return BUNDLED_MANIFESTS.find((m) => Object.prototype.hasOwnProperty.call(m.kinds, kind));
|
|
1486
1529
|
}
|
|
@@ -1498,7 +1541,12 @@ var ENTITY_KINDS = {
|
|
|
1498
1541
|
focus_session: true,
|
|
1499
1542
|
habit: true,
|
|
1500
1543
|
habit_checkin: true,
|
|
1501
|
-
attachment: true
|
|
1544
|
+
attachment: true,
|
|
1545
|
+
"journal.entry": true,
|
|
1546
|
+
"journal.preference": true,
|
|
1547
|
+
"vault.item": true,
|
|
1548
|
+
"vault.folder": true,
|
|
1549
|
+
"vault.preference": true
|
|
1502
1550
|
};
|
|
1503
1551
|
function loadTable(manifests) {
|
|
1504
1552
|
const table = {};
|
|
@@ -1515,6 +1563,12 @@ function loadTable(manifests) {
|
|
|
1515
1563
|
return Object.freeze(table);
|
|
1516
1564
|
}
|
|
1517
1565
|
var PLAINTEXT_FIELDS = loadTable(BUNDLED_MANIFESTS);
|
|
1566
|
+
function shouldEncryptKindField(entity, field) {
|
|
1567
|
+
const plain = appOfKind(entity)?.kinds[entity]?.plaintext_fields;
|
|
1568
|
+
if (plain === "all")
|
|
1569
|
+
return false;
|
|
1570
|
+
return !plain?.includes(field);
|
|
1571
|
+
}
|
|
1518
1572
|
function shouldEncryptField(entity, field) {
|
|
1519
1573
|
const plain = PLAINTEXT_FIELDS[entity];
|
|
1520
1574
|
if (plain === "all")
|
|
@@ -4649,8 +4703,110 @@ function rowToOp(r) {
|
|
|
4649
4703
|
value: r.value_json === null ? undefined : JSON.parse(r.value_json)
|
|
4650
4704
|
};
|
|
4651
4705
|
}
|
|
4652
|
-
// src/
|
|
4706
|
+
// src/sealedPersistence.ts
|
|
4707
|
+
import { hkdf as hkdf7 } from "@noble/hashes/hkdf.js";
|
|
4653
4708
|
import { sha256 as sha2568 } from "@noble/hashes/sha2.js";
|
|
4709
|
+
var LOCAL_STORE_KEY_ID = "local-v1";
|
|
4710
|
+
var SCOPE = { kind: "personal" };
|
|
4711
|
+
function deriveLocalStoreKey(appKey, userId) {
|
|
4712
|
+
if (appKey.length !== 32)
|
|
4713
|
+
throw new Error("the app key must be 32 bytes");
|
|
4714
|
+
return hkdf7(sha2568, appKey, utf8ToBytes(userId), utf8ToBytes("atlas-local-store-v1"), 32);
|
|
4715
|
+
}
|
|
4716
|
+
|
|
4717
|
+
class SealedStoreError extends Error {
|
|
4718
|
+
constructor(message) {
|
|
4719
|
+
super(message);
|
|
4720
|
+
this.name = "SealedStoreError";
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
function seal(key, entity, entityId, field, value) {
|
|
4724
|
+
return encryptField(key, LOCAL_STORE_KEY_ID, SCOPE, { entity, entityId, field }, value);
|
|
4725
|
+
}
|
|
4726
|
+
function open(key, entity, entityId, field, stored) {
|
|
4727
|
+
if (!isFieldEnvelopeV2(stored) || stored.kid !== LOCAL_STORE_KEY_ID) {
|
|
4728
|
+
throw new SealedStoreError(`a stored value of ${entity}.${field} is not sealed`);
|
|
4729
|
+
}
|
|
4730
|
+
try {
|
|
4731
|
+
return decryptField(key, SCOPE, { entity, entityId, field }, stored);
|
|
4732
|
+
} catch {
|
|
4733
|
+
throw new SealedStoreError(`a stored value of ${entity}.${field} does not open with this key`);
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
function sealOp(key, op) {
|
|
4737
|
+
if (op.op !== "set")
|
|
4738
|
+
return op;
|
|
4739
|
+
return { ...op, value: seal(key, op.entity, op.entityId, op.field, op.value) };
|
|
4740
|
+
}
|
|
4741
|
+
function openOp(key, op) {
|
|
4742
|
+
if (op.op !== "set")
|
|
4743
|
+
return op;
|
|
4744
|
+
return { ...op, value: open(key, op.entity, op.entityId, op.field, op.value) };
|
|
4745
|
+
}
|
|
4746
|
+
function sealRepairs(key, repairs) {
|
|
4747
|
+
return repairs.map((r) => ({ ...r, value: seal(key, r.entity, r.entityId, r.field, r.value) }));
|
|
4748
|
+
}
|
|
4749
|
+
function openRepairs(key, repairs) {
|
|
4750
|
+
return repairs.map((r) => ({ ...r, value: open(key, r.entity, r.entityId, r.field, r.value) }));
|
|
4751
|
+
}
|
|
4752
|
+
function sealPersistence(inner, key) {
|
|
4753
|
+
if (key.length !== 32)
|
|
4754
|
+
throw new Error("the local store key must be 32 bytes");
|
|
4755
|
+
const sealed = {
|
|
4756
|
+
load: async () => (await inner.load()).map((p) => ({ ...p, op: openOp(key, p.op) })),
|
|
4757
|
+
append: (op, synced) => inner.append(sealOp(key, op), synced),
|
|
4758
|
+
markSynced: (opIds) => inner.markSynced(opIds),
|
|
4759
|
+
getCursor: () => inner.getCursor(),
|
|
4760
|
+
setCursor: (cursor) => inner.setCursor(cursor)
|
|
4761
|
+
};
|
|
4762
|
+
if (inner.appendBatch) {
|
|
4763
|
+
const appendBatch = inner.appendBatch.bind(inner);
|
|
4764
|
+
sealed.appendBatch = (ops, synced) => appendBatch(ops.map((op) => sealOp(key, op)), synced);
|
|
4765
|
+
}
|
|
4766
|
+
if (inner.getBootstrap) {
|
|
4767
|
+
const getBootstrap = inner.getBootstrap.bind(inner);
|
|
4768
|
+
sealed.getBootstrap = async () => {
|
|
4769
|
+
const progress = await getBootstrap();
|
|
4770
|
+
if (!progress?.repairs)
|
|
4771
|
+
return progress;
|
|
4772
|
+
return { ...progress, repairs: openRepairs(key, progress.repairs) };
|
|
4773
|
+
};
|
|
4774
|
+
}
|
|
4775
|
+
if (inner.setBootstrap) {
|
|
4776
|
+
const setBootstrap = inner.setBootstrap.bind(inner);
|
|
4777
|
+
sealed.setBootstrap = (progress, page) => setBootstrap(progress && progress.repairs ? { ...progress, repairs: sealRepairs(key, progress.repairs) } : progress, page ? { ...page, repairs: sealRepairs(key, page.repairs) } : page);
|
|
4778
|
+
}
|
|
4779
|
+
if (inner.commitSyncBatch) {
|
|
4780
|
+
const commit = inner.commitSyncBatch.bind(inner);
|
|
4781
|
+
sealed.commitSyncBatch = (ops, cursor) => commit(ops.map((op) => sealOp(key, op)), cursor);
|
|
4782
|
+
}
|
|
4783
|
+
if (inner.restamp) {
|
|
4784
|
+
const restamp = inner.restamp.bind(inner);
|
|
4785
|
+
sealed.restamp = (ops) => restamp(ops.map((op) => sealOp(key, op)));
|
|
4786
|
+
}
|
|
4787
|
+
const passThrough = [
|
|
4788
|
+
"clear",
|
|
4789
|
+
"clearSynced",
|
|
4790
|
+
"compact",
|
|
4791
|
+
"deleteOps",
|
|
4792
|
+
"close",
|
|
4793
|
+
"loadAttachmentQueue",
|
|
4794
|
+
"putAttachmentUpload",
|
|
4795
|
+
"deleteAttachmentUpload",
|
|
4796
|
+
"listAttachmentQueue",
|
|
4797
|
+
"loadAttachmentCiphertext",
|
|
4798
|
+
"updateAttachmentUpload"
|
|
4799
|
+
];
|
|
4800
|
+
for (const name of passThrough) {
|
|
4801
|
+
const fn = inner[name];
|
|
4802
|
+
if (typeof fn === "function") {
|
|
4803
|
+
sealed[name] = fn.bind(inner);
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
return sealed;
|
|
4807
|
+
}
|
|
4808
|
+
// src/attachments.ts
|
|
4809
|
+
import { sha256 as sha2569 } from "@noble/hashes/sha2.js";
|
|
4654
4810
|
var DEFAULT_MAX_BLOB_BYTES = 25 * 1024 * 1024;
|
|
4655
4811
|
function maxAttachmentBytes(maxBlobBytes = DEFAULT_MAX_BLOB_BYTES) {
|
|
4656
4812
|
return maxPlainSize(maxBlobBytes);
|
|
@@ -4668,7 +4824,7 @@ function generateAek() {
|
|
|
4668
4824
|
return randomBytes(32);
|
|
4669
4825
|
}
|
|
4670
4826
|
function sha256Hex(bytes) {
|
|
4671
|
-
return bytesToHex(
|
|
4827
|
+
return bytesToHex(sha2569(bytes));
|
|
4672
4828
|
}
|
|
4673
4829
|
function bytesSource(bytes) {
|
|
4674
4830
|
return {
|
|
@@ -4727,8 +4883,8 @@ async function sealSource(aek, source, opts = {}) {
|
|
|
4727
4883
|
const expected = sealedBlobSize(total);
|
|
4728
4884
|
const sink = opts.asBlob ? new BlobSink : new BufferSink(expected);
|
|
4729
4885
|
const sealer = new BlobSealer(aek);
|
|
4730
|
-
const blobHash =
|
|
4731
|
-
const plainHash =
|
|
4886
|
+
const blobHash = sha2569.create();
|
|
4887
|
+
const plainHash = sha2569.create();
|
|
4732
4888
|
let size = 0;
|
|
4733
4889
|
const emit = (bytes) => {
|
|
4734
4890
|
blobHash.update(bytes);
|
|
@@ -4791,7 +4947,7 @@ class PlainCollector {
|
|
|
4791
4947
|
out;
|
|
4792
4948
|
parts = [];
|
|
4793
4949
|
length = 0;
|
|
4794
|
-
hash =
|
|
4950
|
+
hash = sha2569.create();
|
|
4795
4951
|
constructor(blobSize) {
|
|
4796
4952
|
this.out = blobSize !== null && blobSize <= MAX_PREALLOCATION ? new Uint8Array(blobSize) : null;
|
|
4797
4953
|
}
|
|
@@ -4869,7 +5025,7 @@ async function fetchBlob(transport, sha, aek, plainSha) {
|
|
|
4869
5025
|
const download = body instanceof Uint8Array ? { size: body.length, chunks: [body] } : body;
|
|
4870
5026
|
const opener = new BlobOpener(aek);
|
|
4871
5027
|
const plain = new PlainCollector(download.size);
|
|
4872
|
-
const received =
|
|
5028
|
+
const received = sha2569.create();
|
|
4873
5029
|
for await (const piece of download.chunks) {
|
|
4874
5030
|
received.update(piece);
|
|
4875
5031
|
for (const chunk of opener.push(piece))
|
|
@@ -6635,10 +6791,12 @@ export {
|
|
|
6635
6791
|
HlcClock,
|
|
6636
6792
|
IndexedDbPersistence,
|
|
6637
6793
|
InvalidApiTokenError,
|
|
6794
|
+
JOURNAL_APP,
|
|
6638
6795
|
KEY_TRUST_ID,
|
|
6639
6796
|
Keyring,
|
|
6640
6797
|
LEGACY_DISCOVERY,
|
|
6641
6798
|
LEGACY_KDF,
|
|
6799
|
+
LOCAL_STORE_KEY_ID,
|
|
6642
6800
|
LocalStore,
|
|
6643
6801
|
MAX_CLOCK_DRIFT_MS,
|
|
6644
6802
|
MAX_COUNTER,
|
|
@@ -6665,6 +6823,7 @@ export {
|
|
|
6665
6823
|
SNAPSHOT_PAGE_KEYS,
|
|
6666
6824
|
SYNC_APPS_HEADER,
|
|
6667
6825
|
SYNC_PROTOCOL,
|
|
6826
|
+
SealedStoreError,
|
|
6668
6827
|
ServerIncompatibleError,
|
|
6669
6828
|
SqlitePersistence,
|
|
6670
6829
|
SyncClient,
|
|
@@ -6714,6 +6873,7 @@ export {
|
|
|
6714
6873
|
deriveApiTokenKeys,
|
|
6715
6874
|
deriveAuthAndMek,
|
|
6716
6875
|
deriveAuthAndMekAsync,
|
|
6876
|
+
deriveLocalStoreKey,
|
|
6717
6877
|
derivePasskeySlotKey,
|
|
6718
6878
|
deriveRecoveryAuthKeypair,
|
|
6719
6879
|
deriveRecoveryKey,
|
|
@@ -6815,6 +6975,7 @@ export {
|
|
|
6815
6975
|
sameKeyScope,
|
|
6816
6976
|
sealDelivery,
|
|
6817
6977
|
sealKey,
|
|
6978
|
+
sealPersistence,
|
|
6818
6979
|
sealRootKey,
|
|
6819
6980
|
sealSource,
|
|
6820
6981
|
sealedBlobSize,
|
|
@@ -6824,6 +6985,7 @@ export {
|
|
|
6824
6985
|
setPbkdf2Provider,
|
|
6825
6986
|
sharedProjectIds,
|
|
6826
6987
|
shouldEncryptField,
|
|
6988
|
+
shouldEncryptKindField,
|
|
6827
6989
|
signDelivery,
|
|
6828
6990
|
signInWithPasskey,
|
|
6829
6991
|
signingPublicKey,
|
|
@@ -6832,6 +6994,7 @@ export {
|
|
|
6832
6994
|
toEncryptedWire,
|
|
6833
6995
|
toWireReport,
|
|
6834
6996
|
todoAppKeyRow,
|
|
6997
|
+
tokenPolicyFor,
|
|
6835
6998
|
unpadPlaintext,
|
|
6836
6999
|
unpinMemberKey,
|
|
6837
7000
|
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/types.d.ts
CHANGED
|
@@ -330,6 +330,10 @@ export interface ApiTokenView {
|
|
|
330
330
|
apps?: string[];
|
|
331
331
|
created_at: number;
|
|
332
332
|
last_used_at?: number;
|
|
333
|
+
/** When the token stops working; absent when it never does. An expired token stays listed until revoked. */
|
|
334
|
+
expires_at?: number;
|
|
335
|
+
/** Whether its sessions may write (absent from servers predating read-only tokens: they may). */
|
|
336
|
+
write?: boolean;
|
|
333
337
|
}
|
|
334
338
|
/** One non-todo app's key and X25519 identity wrapped for an API token (`wrapAppKeysForApiToken`). */
|
|
335
339
|
export interface ApiTokenAppKeyWraps {
|
|
@@ -365,6 +369,14 @@ export interface CreateApiTokenPayload {
|
|
|
365
369
|
ct: string;
|
|
366
370
|
};
|
|
367
371
|
app_keys?: Record<string, ApiTokenAppKeyWraps>;
|
|
372
|
+
/**
|
|
373
|
+
* When the token stops working (unix ms). The apps' policy ({@link tokenPolicyFor}) may require
|
|
374
|
+
* one and cap how far out it is: a vault token needs one at most 365 days ahead (needs the
|
|
375
|
+
* `api_token_policies` capability).
|
|
376
|
+
*/
|
|
377
|
+
expires_at?: number;
|
|
378
|
+
/** Whether its sessions may write; the policy's default when absent (a vault token is read-only). */
|
|
379
|
+
write?: boolean;
|
|
368
380
|
}
|
|
369
381
|
/** `POST /auth/api-token/session`: an access token (no refresh token) and the token's wraps. */
|
|
370
382
|
export interface ApiTokenSessionResponse {
|
|
@@ -393,6 +405,10 @@ export interface ApiTokenSessionResponse {
|
|
|
393
405
|
iv: string;
|
|
394
406
|
ct: string;
|
|
395
407
|
};
|
|
408
|
+
/** When the token expires (unix ms); `expires_in` ends no later. Absent when it never does. */
|
|
409
|
+
expires_at?: number;
|
|
410
|
+
/** Whether the session may write; a read-only one gets 403 `api_token_read_only` on writes. */
|
|
411
|
+
write?: boolean;
|
|
396
412
|
}
|
|
397
413
|
export interface MemberView {
|
|
398
414
|
user_id: string;
|
|
@@ -442,7 +458,17 @@ export type Operation = {
|
|
|
442
458
|
});
|
|
443
459
|
export type EntityKind = "task" | "project" | "section" | "label" | "comment" | "preference" | "saved_filter" | "reminder" | "project_member" | "activity" | "focus_session" | "habit" | "habit_checkin"
|
|
444
460
|
/** Metadata only; ciphertext travels via `/attachments/blobs/:sha256`. */
|
|
445
|
-
| "attachment"
|
|
461
|
+
| "attachment"
|
|
462
|
+
/**
|
|
463
|
+
* Atlas Journal's kinds (`manifests/journal.json`), namespaced `<app>.<kind>` like every app's
|
|
464
|
+
* after todo. Personal and fully encrypted: no plaintext field, no project scope. A journal
|
|
465
|
+
* client syncs them in a journal-scoped session (`ApiClientOptions.apps`).
|
|
466
|
+
*/
|
|
467
|
+
| JournalEntityKind
|
|
468
|
+
/** Atlas Vault (`manifests/vault.json`): personal, every field encrypted. */
|
|
469
|
+
| "vault.item" | "vault.folder" | "vault.preference";
|
|
470
|
+
/** Atlas Journal's entity kinds: `journal.entry` (one entry) and `journal.preference` (a setting). */
|
|
471
|
+
export type JournalEntityKind = "journal.entry" | "journal.preference";
|
|
446
472
|
export interface CreateTaskInput {
|
|
447
473
|
title: string;
|
|
448
474
|
notes?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlastodo/client-core",
|
|
3
|
-
"version": "0.1.11-rc.
|
|
3
|
+
"version": "0.1.11-rc.9",
|
|
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",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"api_token_vectors.json",
|
|
37
37
|
"conflict_vectors.json",
|
|
38
38
|
"envelope_vectors.json",
|
|
39
|
+
"field_policy_vectors.json",
|
|
39
40
|
"identity_vectors.json",
|
|
40
41
|
"passkey_vectors.json",
|
|
41
42
|
"password_kdf_vectors.json",
|
|
@@ -34,6 +34,20 @@
|
|
|
34
34
|
"iv": "dXrtP9iobV/5dQK7",
|
|
35
35
|
"ct": "YVQDhrVqw2A7XxrJ1DBI/qTTTVQ0FlPSaGNiMxAE5GyMh3JDOvj6Sl4JjdNi0xQY"
|
|
36
36
|
}
|
|
37
|
+
},
|
|
38
|
+
"vault": {
|
|
39
|
+
"app_key": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
|
40
|
+
"app_private_key": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
41
|
+
"wrapped_key": {
|
|
42
|
+
"v": 1,
|
|
43
|
+
"iv": "wm+GsbI61xROnC8+",
|
|
44
|
+
"ct": "3wMTljSLoopA53SXjhbdhPFksiuOdMXNmj7HGqiU+ByMqb794Vb57+NVh0Br9FZv"
|
|
45
|
+
},
|
|
46
|
+
"wrapped_private_key": {
|
|
47
|
+
"v": 1,
|
|
48
|
+
"iv": "/QKoOwtHubDapdW1",
|
|
49
|
+
"ct": "UFyTM//C7trgffAR2i0JDdLwtRf6I/9l+eq6oixMefK3KtXZixSE6RF5Flrdzf7C"
|
|
50
|
+
}
|
|
37
51
|
}
|
|
38
52
|
}
|
|
39
53
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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; `exclusive`: the server refuses a token naming such an app with any other).",
|
|
3
|
+
"fields": [
|
|
4
|
+
{ "entity": "task", "field": "title", "encrypted": true },
|
|
5
|
+
{ "entity": "task", "field": "project_id", "encrypted": false },
|
|
6
|
+
{ "entity": "task", "field": "assignee_id", "encrypted": false },
|
|
7
|
+
{ "entity": "task", "field": "a_field_nobody_declared", "encrypted": true },
|
|
8
|
+
{ "entity": "comment", "field": "task_id", "encrypted": false },
|
|
9
|
+
{ "entity": "attachment", "field": "blob_sha", "encrypted": false },
|
|
10
|
+
{ "entity": "project_member", "field": "anything", "encrypted": false },
|
|
11
|
+
{ "entity": "journal.entry", "field": "body", "encrypted": true },
|
|
12
|
+
{ "entity": "journal.entry", "field": "date", "encrypted": true },
|
|
13
|
+
{ "entity": "journal.entry", "field": "project_id", "encrypted": true },
|
|
14
|
+
{ "entity": "vault.item", "field": "type", "encrypted": true },
|
|
15
|
+
{ "entity": "vault.item", "field": "name", "encrypted": true },
|
|
16
|
+
{ "entity": "vault.item", "field": "fields", "encrypted": true },
|
|
17
|
+
{ "entity": "vault.item", "field": "urls", "encrypted": true },
|
|
18
|
+
{ "entity": "vault.item", "field": "notes", "encrypted": true },
|
|
19
|
+
{ "entity": "vault.item", "field": "tags", "encrypted": true },
|
|
20
|
+
{ "entity": "vault.item", "field": "folder", "encrypted": true },
|
|
21
|
+
{ "entity": "vault.item", "field": "favorite", "encrypted": true },
|
|
22
|
+
{ "entity": "vault.item", "field": "history", "encrypted": true },
|
|
23
|
+
{ "entity": "vault.item", "field": "created_at", "encrypted": true },
|
|
24
|
+
{ "entity": "vault.item", "field": "updated_at", "encrypted": true },
|
|
25
|
+
{ "entity": "vault.item", "field": "deleted_at", "encrypted": true },
|
|
26
|
+
{ "entity": "vault.item", "field": "project_id", "encrypted": true },
|
|
27
|
+
{ "entity": "vault.item", "field": "task_id", "encrypted": true },
|
|
28
|
+
{ "entity": "vault.folder", "field": "name", "encrypted": true },
|
|
29
|
+
{ "entity": "vault.folder", "field": "deleted_at", "encrypted": true },
|
|
30
|
+
{ "entity": "vault.preference", "field": "value", "encrypted": true },
|
|
31
|
+
{ "entity": "notes.note", "field": "body", "encrypted": true },
|
|
32
|
+
{ "entity": "notes.note", "field": "project_id", "encrypted": true }
|
|
33
|
+
],
|
|
34
|
+
"token_policies": [
|
|
35
|
+
{
|
|
36
|
+
"apps": ["todo"],
|
|
37
|
+
"policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true, "exclusive": false }
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"apps": ["journal"],
|
|
41
|
+
"policy": { "expiry_required": false, "max_lifetime_days": null, "write_by_default": true, "exclusive": false }
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"apps": ["vault"],
|
|
45
|
+
"policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false, "exclusive": true }
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"apps": ["todo", "journal", "vault"],
|
|
49
|
+
"policy": { "expiry_required": true, "max_lifetime_days": 365, "write_by_default": false, "exclusive": true }
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|