@atlastodo/client-core 0.1.13 → 0.1.14-rc.1
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/api.d.ts +26 -20
- package/dist/crypto/envelope.d.ts +4 -4
- package/dist/crypto/field.d.ts +52 -9
- package/dist/crypto/index.d.ts +1 -0
- package/dist/crypto/invitePreview.d.ts +23 -0
- package/dist/fieldPolicy.d.ts +37 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.js +650 -108
- package/dist/legacy.d.ts +46 -0
- package/dist/realtime.d.ts +3 -2
- package/dist/scope.d.ts +1 -1
- package/dist/store.d.ts +22 -0
- package/dist/sync-client.d.ts +17 -0
- package/dist/types.d.ts +7 -1
- package/dist/wire.d.ts +100 -0
- package/package.json +1 -1
- package/test-vectors/envelope_vectors.json +171 -2
- package/test-vectors/field_policy_vectors.json +335 -38
package/dist/api.d.ts
CHANGED
|
@@ -1,31 +1,22 @@
|
|
|
1
1
|
/** Typed HTTP client: auth, key distribution, membership, reports, admin and `/sync/*`. A 401 rotates the refresh token once. */
|
|
2
2
|
import type { AdminAuditEntry, AdminInviteView, AdminSettingsView, AdminUserView, AuthResponse, AuthUser, BugReportPayload, BugReportSummary, BugReportView, InviteView, MemberRole, MemberView, MissingProjectKey, PendingKeyRotation, Operation, ProjectKeysResponse, RecoverAccountPayload, RecoveryKeysResponse, RegisterRecoveryKeyPayload, ReplaceRecoveryKeyPayload, SaltResponse, SessionView, ApiTokenView, ApiTokenSessionResponse, CreateApiTokenPayload, UserPublicKeyResponse, SigningKeyResponse, ChangePasswordPayload, AppKeyRow, MigrateRootKeyPayload, PutRecoveryRootKeyPayload, RootKeyResponse, AccountProof, PasskeyAssertionResponse, PasskeyCreationOptions, PasskeyReauthResponse, PasskeyRegistrationPayload, PasskeyRequestOptions, PasskeyView } from "./types.js";
|
|
3
|
-
import { Keyring, type EncryptedPayload, type PasswordKdf, type SealedKey, type WrappedProjectKey } from "./crypto/index.js";
|
|
3
|
+
import { Keyring, type EncryptedPayload, type PasswordKdf, type SealedInvitePreview, type SealedKey, type WrappedProjectKey } from "./crypto/index.js";
|
|
4
4
|
import { type ServerDiscovery } from "./discovery.js";
|
|
5
5
|
import { type Repair, type ScopeStore } from "./scope.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
entity_id: string;
|
|
10
|
-
ts: {
|
|
11
|
-
wall_ms: number;
|
|
12
|
-
counter: number;
|
|
13
|
-
node: string;
|
|
14
|
-
};
|
|
15
|
-
} & ({
|
|
16
|
-
op: "set";
|
|
17
|
-
field: string;
|
|
18
|
-
value: unknown;
|
|
19
|
-
} | {
|
|
20
|
-
op: "delete";
|
|
21
|
-
});
|
|
6
|
+
import { type WireOp } from "./wire.js";
|
|
7
|
+
import type { LegacyKeysPage } from "./legacy.js";
|
|
8
|
+
export type { WireOp } from "./wire.js";
|
|
22
9
|
/** Sync without an unlocked keyring would push plaintext or store ciphertext as content. Transient. */
|
|
23
10
|
export declare class E2eeLockedError extends Error {
|
|
24
11
|
constructor();
|
|
25
12
|
}
|
|
26
13
|
/** Explicit scope: `projectId`'s key (throws when not held) or the DEK when null; never falls back. */
|
|
27
14
|
export declare function toEncryptedWire(op: Operation, keyring?: Keyring | null, projectId?: string | null): WireOp;
|
|
28
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* One wire op, decoded on its own: a current one opened with the keys of its scope (still sealed
|
|
17
|
+
* without them), a legacy one by the protocol 6 rules, where `projectId` null is personal and
|
|
18
|
+
* undefined unknown (any held key may open). Throws for an op the decoder drops.
|
|
19
|
+
*/
|
|
29
20
|
export declare function fromEncryptedWire(w: WireOp, keyring?: Keyring | null, projectId?: string | null): Operation;
|
|
30
21
|
type WireReport = {
|
|
31
22
|
id: string;
|
|
@@ -148,6 +139,8 @@ export declare class ApiClient {
|
|
|
148
139
|
/** Legacy migration done: plaintext in encrypted fields and v1 values are then dropped. Local, so the server cannot roll it back. */
|
|
149
140
|
setLegacyMigrated(done: boolean): void;
|
|
150
141
|
isLegacyMigrated(): boolean;
|
|
142
|
+
/** The real kind of an entity the attached store holds by this id. */
|
|
143
|
+
private kindOfEntity;
|
|
151
144
|
private isCanonical;
|
|
152
145
|
private unlockedKeyring;
|
|
153
146
|
setTokens(token: string | undefined, refreshToken?: string): void;
|
|
@@ -385,10 +378,24 @@ export declare class ApiClient {
|
|
|
385
378
|
from?: number;
|
|
386
379
|
repairs: Repair[];
|
|
387
380
|
};
|
|
381
|
+
/** The protocol 6 entities this partition still holds, a page at a time (`migrateLegacy`). */
|
|
382
|
+
legacyKeys(next?: string): Promise<LegacyKeysPage>;
|
|
383
|
+
/** Drop re-encoded protocol 6 entities from this partition; answers how many it still held. */
|
|
384
|
+
dropLegacy(keys: {
|
|
385
|
+
entity: string;
|
|
386
|
+
entity_id: string;
|
|
387
|
+
}[]): Promise<number>;
|
|
388
|
+
/**
|
|
389
|
+
* Whether this account may re-write every field of the entity: its own activity only (the server
|
|
390
|
+
* names the pusher as the actor), a shared project only as its owner (archive and delete are
|
|
391
|
+
* owner-only), a shared project's item only as an active member.
|
|
392
|
+
*/
|
|
393
|
+
mayReencode(kind: Operation["entity"], entityId: string): boolean;
|
|
388
394
|
/** WebSockets cannot set headers and proxies may log the URL, so it carries a single-use 30 s ticket, never the access token. */
|
|
389
395
|
syncWsUrl(since: number): Promise<string | null>;
|
|
390
396
|
listMembers(projectId: string): Promise<MemberView[]>;
|
|
391
|
-
|
|
397
|
+
/** `preview`: what the invitee is shown before accepting (`sealInvitePreview`). */
|
|
398
|
+
inviteMember(projectId: string, email: string, role: MemberRole, preview?: SealedInvitePreview): Promise<MemberView>;
|
|
392
399
|
acceptInvite(projectId: string): Promise<void>;
|
|
393
400
|
declineInvite(projectId: string): Promise<void>;
|
|
394
401
|
updateMemberRole(projectId: string, userId: string, role: MemberRole): Promise<MemberView>;
|
|
@@ -396,4 +403,3 @@ export declare class ApiClient {
|
|
|
396
403
|
claimProjectOwnership(projectId: string): Promise<MemberView>;
|
|
397
404
|
listInvites(): Promise<InviteView[]>;
|
|
398
405
|
}
|
|
399
|
-
export {};
|
|
@@ -48,16 +48,16 @@ export declare function unwrapAttachmentKey(wrapped: WrappedAttachmentKey, key:
|
|
|
48
48
|
*/
|
|
49
49
|
export declare function isWrappedAttachmentKey(v: unknown): v is WrappedAttachmentKey;
|
|
50
50
|
/**
|
|
51
|
-
* Whether a field value is still in encrypted wire form (`{ __enc:
|
|
52
|
-
* `__enc: 1`). A field no key here opens is stored as this object, so its presence
|
|
53
|
-
* "unreadable here", not "empty".
|
|
51
|
+
* Whether a field value is still in encrypted wire form (`{ __enc: 3, kid, iv, ct }`, or the older
|
|
52
|
+
* `__enc: 2` and `__enc: 1`). A field no key here opens is stored as this object, so its presence
|
|
53
|
+
* means "unreadable here", not "empty".
|
|
54
54
|
*/
|
|
55
55
|
export declare function isEncryptedEnvelope(v: unknown): v is {
|
|
56
56
|
__enc: 1;
|
|
57
57
|
iv: string;
|
|
58
58
|
ct: string;
|
|
59
59
|
} | {
|
|
60
|
-
__enc: 2;
|
|
60
|
+
__enc: 2 | 3;
|
|
61
61
|
kid: string;
|
|
62
62
|
iv: string;
|
|
63
63
|
ct: string;
|
package/dist/crypto/field.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Field-value encryption
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Field-value encryption. Version 3 (`sealField`, sync protocol 7) is what clients write: the
|
|
3
|
+
* plaintext holds the real kind and field name too, and the wire carries an opaque tag
|
|
4
|
+
* (`fieldTag`) instead of the field name. Version 2 (`{ __enc: 2, kid, iv, ct }`) is read for rows
|
|
5
|
+
* written before protocol 7. In both, the AES-GCM associated data binds a value to its envelope
|
|
6
|
+
* version, wire kind and entity id, wire field, key scope and key id, so ciphertext the server
|
|
7
|
+
* copies elsewhere fails to open instead of being read or re-encrypted there. Both pad the JSON
|
|
8
|
+
* plaintext to a size bucket so length does not reveal `true` from `false` or a short title from a
|
|
9
|
+
* long one. Version 1 (unpadded, no associated data) is read, never written.
|
|
7
10
|
*/
|
|
8
11
|
export declare const FIELD_ENVELOPE_VERSION = 2;
|
|
9
12
|
export type KeyScope = {
|
|
@@ -26,12 +29,12 @@ export interface FieldEnvelopeV2 {
|
|
|
26
29
|
export declare function sameKeyScope(a: KeyScope, b: KeyScope): boolean;
|
|
27
30
|
export declare function keyScopeLabel(scope: KeyScope): string;
|
|
28
31
|
/**
|
|
29
|
-
* The padded size for `n` bytes of plaintext plus the one-byte pad marker: at least 32
|
|
30
|
-
* next power of two up to 1 KiB, then the next multiple of 1 KiB.
|
|
32
|
+
* The padded size for `n` bytes of plaintext plus the one-byte pad marker: at least `min` (32 for
|
|
33
|
+
* version 2), then the next power of two up to 1 KiB, then the next multiple of 1 KiB.
|
|
31
34
|
*/
|
|
32
|
-
export declare function paddedLength(n: number): number;
|
|
35
|
+
export declare function paddedLength(n: number, min?: number): number;
|
|
33
36
|
/** Append 0x80 and zeros up to the bucket size (ISO/IEC 7816-4 padding). */
|
|
34
|
-
export declare function padPlaintext(data: Uint8Array): Uint8Array;
|
|
37
|
+
export declare function padPlaintext(data: Uint8Array, min?: number): Uint8Array;
|
|
35
38
|
export declare function unpadPlaintext(data: Uint8Array): Uint8Array;
|
|
36
39
|
export declare function encryptField(key: Uint8Array, kid: string, scope: KeyScope, at: FieldLocation, value: unknown): FieldEnvelopeV2;
|
|
37
40
|
/**
|
|
@@ -39,4 +42,44 @@ export declare function encryptField(key: Uint8Array, kid: string, scope: KeySco
|
|
|
39
42
|
* when any of that is untrue: another key, another scope, or a value copied from elsewhere.
|
|
40
43
|
*/
|
|
41
44
|
export declare function decryptField(key: Uint8Array, scope: KeyScope, at: FieldLocation, envelope: FieldEnvelopeV2): unknown;
|
|
45
|
+
/** Sync protocol 7's envelope (docs/decisions/0006). */
|
|
46
|
+
export declare const FIELD_ENVELOPE_V3 = 3;
|
|
47
|
+
/**
|
|
48
|
+
* A v3 plaintext carries the kind and field name too, so its smallest bucket is 64 bytes: a small
|
|
49
|
+
* value of a long-named field must not fall in another bucket than one of a short-named field.
|
|
50
|
+
*/
|
|
51
|
+
export declare const FIELD_V3_MIN_PADDING = 64;
|
|
52
|
+
export interface FieldEnvelopeV3 {
|
|
53
|
+
__enc: 3;
|
|
54
|
+
kid: string;
|
|
55
|
+
iv: string;
|
|
56
|
+
ct: string;
|
|
57
|
+
}
|
|
58
|
+
/** Where a v3 envelope sits on the wire: the wire kind and wire field (a tag or a public name). */
|
|
59
|
+
export interface WireLocation {
|
|
60
|
+
entity: string;
|
|
61
|
+
entityId: string;
|
|
62
|
+
field: string;
|
|
63
|
+
}
|
|
64
|
+
/** What a v3 envelope holds. */
|
|
65
|
+
export interface SealedField {
|
|
66
|
+
kind: string;
|
|
67
|
+
field: string;
|
|
68
|
+
value: unknown;
|
|
69
|
+
}
|
|
70
|
+
export declare function sealField(key: Uint8Array, kid: string, scope: KeyScope, at: WireLocation, sealed: SealedField): FieldEnvelopeV3;
|
|
71
|
+
/**
|
|
72
|
+
* Open a v3 envelope that claims to sit at `at` under `key` of `scope`. Throws when any of that is
|
|
73
|
+
* untrue, or when the plaintext is not a `[kind, field, value]` triple.
|
|
74
|
+
*/
|
|
75
|
+
export declare function openSealedField(key: Uint8Array, scope: KeyScope, at: WireLocation, envelope: FieldEnvelopeV3): SealedField;
|
|
76
|
+
export declare function isFieldEnvelopeV3(v: unknown): v is FieldEnvelopeV3;
|
|
77
|
+
/** The key a scope's field tags are made with: HKDF-SHA256 of the encrypting key. */
|
|
78
|
+
export declare function fieldTagKey(key: Uint8Array): Uint8Array;
|
|
79
|
+
/**
|
|
80
|
+
* The opaque wire name of `field` of `kind` entity `entityId`: 32 lowercase hex digits. The entity
|
|
81
|
+
* id is part of it, so the same field of two entities has unrelated tags.
|
|
82
|
+
*/
|
|
83
|
+
export declare function fieldTag(tagKey: Uint8Array, entityId: string, kind: string, field: string): string;
|
|
84
|
+
export declare function isFieldTag(name: string): boolean;
|
|
42
85
|
export declare function isFieldEnvelopeV2(v: unknown): v is FieldEnvelopeV2;
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an invitee is shown before accepting (sync protocol 7): the project's name, icon, color and
|
|
3
|
+
* kind, which the server cannot read any more, encrypted by the inviter under the project key the
|
|
4
|
+
* invitee receives sealed with the invite. Bound to the project and the key id, and padded like a
|
|
5
|
+
* field value, so the server can neither move it to another project nor learn the name's length.
|
|
6
|
+
*/
|
|
7
|
+
export interface InvitePreview {
|
|
8
|
+
name?: unknown;
|
|
9
|
+
icon?: unknown;
|
|
10
|
+
color?: unknown;
|
|
11
|
+
kind?: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface SealedInvitePreview {
|
|
14
|
+
v: 1;
|
|
15
|
+
kid: string;
|
|
16
|
+
iv: string;
|
|
17
|
+
ct: string;
|
|
18
|
+
}
|
|
19
|
+
/** Under project key `pek` (id `kid`) of `projectId`. */
|
|
20
|
+
export declare function sealInvitePreview(pek: Uint8Array, kid: string, projectId: string, preview: InvitePreview): SealedInvitePreview;
|
|
21
|
+
/** Throws unless `pek` sealed it for `projectId`. */
|
|
22
|
+
export declare function openInvitePreview(pek: Uint8Array, projectId: string, sealed: SealedInvitePreview): InvitePreview;
|
|
23
|
+
export declare function isSealedInvitePreview(v: unknown): v is SealedInvitePreview;
|
package/dist/fieldPolicy.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* What of an entity leaves the device readable (sync protocol 7, docs/decisions/0006): the wire
|
|
3
|
+
* kind it travels as instead of its real kind, the few field names that travel as they are instead
|
|
4
|
+
* of an opaque tag (`public_fields`), and which of those carry a plaintext value
|
|
5
|
+
* (`plaintext_fields`). Everything else, including fields nothing declares yet, travels under a
|
|
6
|
+
* tag in an encrypted value, so a new field cannot leak because a list was forgotten.
|
|
4
7
|
*
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
+
* The tables are the bundled app manifests (`manifests/<app>.json`), which the server compiles in
|
|
9
|
+
* too. `legacy_plaintext_fields` is what protocol 6 sent unencrypted, which reading the rows
|
|
10
|
+
* written before protocol 7 needs.
|
|
8
11
|
*/
|
|
9
12
|
import type { EntityKind } from "./types.js";
|
|
10
13
|
/**
|
|
@@ -22,13 +25,18 @@ export interface TokenPolicy {
|
|
|
22
25
|
exclusive: boolean;
|
|
23
26
|
}
|
|
24
27
|
/** A bundled app manifest: its id, version, the entity kinds it syncs and its token policy. */
|
|
28
|
+
export interface KindManifest {
|
|
29
|
+
/** The kind the server sees. */
|
|
30
|
+
wire: string;
|
|
31
|
+
public_fields?: readonly string[];
|
|
32
|
+
plaintext_fields?: readonly string[] | "all";
|
|
33
|
+
legacy_plaintext_fields?: readonly string[];
|
|
34
|
+
}
|
|
25
35
|
export interface AppManifest {
|
|
26
36
|
app: string;
|
|
27
37
|
name: string;
|
|
28
38
|
version: number;
|
|
29
|
-
kinds: Record<string,
|
|
30
|
-
plaintext_fields: readonly string[] | "all";
|
|
31
|
-
}>;
|
|
39
|
+
kinds: Record<string, KindManifest>;
|
|
32
40
|
api_tokens?: Partial<TokenPolicy>;
|
|
33
41
|
}
|
|
34
42
|
/**
|
|
@@ -47,10 +55,29 @@ export declare const BUNDLED_MANIFESTS: readonly AppManifest[];
|
|
|
47
55
|
export declare function tokenPolicyFor(apps: readonly string[]): TokenPolicy;
|
|
48
56
|
/** The bundled app owning the kind with this wire name, if any. */
|
|
49
57
|
export declare function appOfKind(kind: string): AppManifest | undefined;
|
|
58
|
+
/** Per real kind, the fields whose value travels unencrypted. */
|
|
50
59
|
export declare const PLAINTEXT_FIELDS: Readonly<Record<EntityKind, readonly string[] | "all">>;
|
|
51
60
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
61
|
+
* The app owning wire name `name`: a wire kind (`todo.sealed`), or a real kind name, which rows
|
|
62
|
+
* written before protocol 7 carry.
|
|
54
63
|
*/
|
|
64
|
+
export declare function appOfWire(name: string): AppManifest | undefined;
|
|
65
|
+
/** The wire kind `kind` travels as; null for a kind no bundled manifest names. */
|
|
66
|
+
export declare function wireKindOf(kind: string): string | null;
|
|
67
|
+
/** Whether `field` of `kind` goes on the wire under its name rather than an opaque tag. */
|
|
68
|
+
export declare function isPublicField(kind: string, field: string): boolean;
|
|
69
|
+
/** Whether a value of `kind`.`field` leaves the device encrypted (protocol 7). */
|
|
55
70
|
export declare function shouldEncryptKindField(entity: string, field: string): boolean;
|
|
56
71
|
export declare function shouldEncryptField(entity: EntityKind, field: string): boolean;
|
|
72
|
+
/** Whether protocol 6 encrypted a value of `kind`.`field`: how a legacy row is read. */
|
|
73
|
+
export declare function legacyEncrypted(kind: string, field: string): boolean;
|
|
74
|
+
/** Whether `name` is the wire kind of rows written before protocol 7: a real kind that now
|
|
75
|
+
* travels as another. */
|
|
76
|
+
export declare function isLegacyWireKind(name: string): boolean;
|
|
77
|
+
/** The real kinds that travel as wire kind `wire`. */
|
|
78
|
+
export declare function kindsOfWire(wire: string): EntityKind[];
|
|
79
|
+
/**
|
|
80
|
+
* The real kind a public field names on wire kind `wire` (`blob_sha` on `todo.sealed` is an
|
|
81
|
+
* attachment's), or the wire kind's only real kind; null when neither tells.
|
|
82
|
+
*/
|
|
83
|
+
export declare function realKindOf(wire: string, publicField?: string): EntityKind | null;
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export * from "./attachments.js";
|
|
|
20
20
|
export * from "./indexeddb.js";
|
|
21
21
|
export * from "./expo-sqlite.js";
|
|
22
22
|
export * from "./sync-client.js";
|
|
23
|
+
export * from "./wire.js";
|
|
24
|
+
export * from "./legacy.js";
|
|
23
25
|
export * from "./realtime.js";
|
|
24
26
|
export * from "./session.js";
|
|
25
27
|
export * from "./password.js";
|