@chipmobilesdk/rn-local-db 0.1.2 → 0.2.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +122 -52
  2. package/README.md +663 -661
  3. package/package.json +59 -62
  4. package/skills/sdk-rn-local-db/SKILL.md +0 -1
  5. package/src/adapter/budgets.ts +67 -67
  6. package/src/adapter/collection.ts +303 -298
  7. package/src/adapter/deletion.ts +252 -238
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -444
  10. package/src/adapter/transaction.ts +71 -65
  11. package/src/artifacts/availability.ts +67 -69
  12. package/src/artifacts/cascade.ts +114 -78
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -323
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -72
  18. package/src/contract/backup.ts +28 -28
  19. package/src/contract/config.ts +54 -54
  20. package/src/contract/encryption.ts +34 -34
  21. package/src/contract/errors.ts +76 -76
  22. package/src/contract/migration.ts +42 -40
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -42
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -46
  28. package/src/crypto/chunked-cipher.ts +153 -144
  29. package/src/crypto/derive.ts +64 -63
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -141
  37. package/src/index.ts +80 -110
  38. package/src/migration/executor.ts +132 -136
  39. package/src/migration/ledger.ts +95 -97
  40. package/src/migration/validate-chain.ts +65 -65
  41. package/src/platform/backup-android-verify.ts +88 -88
  42. package/src/platform/backup-ios.ts +48 -48
  43. package/src/platform/bytes.ts +144 -0
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -57
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -99
  49. package/src/query/filter.ts +82 -82
  50. package/src/query/paginate.ts +51 -51
  51. package/src/query/sort.ts +55 -56
  52. package/src/query/validate.ts +171 -171
  53. package/src/schema/declare.ts +42 -45
  54. package/src/schema/validate-config.ts +158 -158
  55. package/src/scope/hash.ts +131 -134
  56. package/src/scope/normalize.ts +83 -90
  57. package/src/scope/validate.ts +83 -83
  58. package/src/sqlite/adapter.ts +26 -26
  59. package/src/sqlite/batch.ts +242 -232
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -205
  62. package/src/sqlite/index-coverage.ts +54 -53
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -201
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -0
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -150
  71. package/src/testing/conformance/index.ts +161 -161
  72. package/src/testing/conformance/lifecycle.ts +99 -99
  73. package/src/testing/conformance/migrations.ts +177 -177
  74. package/src/testing/conformance/queries.ts +204 -167
  75. package/src/testing/conformance/records.ts +169 -169
  76. package/src/testing/conformance/scopes.ts +86 -88
  77. package/src/testing/conformance/transactions.ts +149 -149
  78. package/src/testing/faults.ts +21 -21
  79. package/src/testing/index.ts +17 -21
  80. package/src/testing/memory-adapter/collection.ts +214 -219
  81. package/src/testing/memory-adapter/index.ts +510 -388
  82. package/src/testing/memory-adapter/migrations.ts +117 -119
  83. package/src/testing/memory-adapter/reset.ts +50 -50
  84. package/src/testing/memory-adapter/store.ts +128 -134
@@ -1,46 +1,52 @@
1
- /**
2
- * Transactions and batches.
3
- *
4
- * A transaction never spans two scopes (FR-038): with one database file per
5
- * scope, a cross-scope transaction is not expressible rather than merely
6
- * discouraged.
7
- */
8
-
9
- import type { FilterNode, Query, ResultPage } from './query';
10
- import type { RecordInput, StorableRecordData, StoredRecord, WriteOutcome } from './record';
11
-
12
- /** Collection operations, available on a handle and inside a transaction. */
13
- export type CollectionOps<T extends StorableRecordData = StorableRecordData> = {
14
- insert(record: RecordInput<T>): Promise<WriteOutcome>;
15
- update(id: string, record: Partial<RecordInput<T>> & { data: T }): Promise<WriteOutcome>;
16
- upsert(record: RecordInput<T>): Promise<WriteOutcome>;
17
- /** Rejects with RECORD_NOT_FOUND when absent (FR-027). */
18
- get(id: string): Promise<StoredRecord<T>>;
19
- /** Null-returning variant, so absence stays distinguishable at the call site. */
20
- find(id: string): Promise<StoredRecord<T> | null>;
21
- delete(id: string): Promise<WriteOutcome>;
22
- softDelete(id: string): Promise<WriteOutcome>;
23
- restore(id: string): Promise<WriteOutcome>;
24
- list(query?: Query): Promise<ResultPage<T>>;
25
- count(filter?: FilterNode): Promise<number>;
26
- };
27
-
28
- export type Transaction = {
29
- collection<T extends StorableRecordData = StorableRecordData>(
30
- name: string,
31
- ): CollectionOps<T>;
32
- /** Discards every write in this transaction (FR-041). */
33
- abort(): Promise<void>;
34
- };
35
-
36
- export type BatchOperation =
37
- | { type: 'insert'; collection: string; record: RecordInput }
38
- | { type: 'update'; collection: string; id: string; data: StorableRecordData }
39
- | { type: 'upsert'; collection: string; record: RecordInput }
40
- | { type: 'delete'; collection: string; id: string }
41
- | { type: 'softDelete'; collection: string; id: string };
42
-
43
- export type BatchOutcome = {
44
- applied: number;
45
- outcomes: WriteOutcome[];
46
- };
1
+ /**
2
+ * Transactions and batches.
3
+ *
4
+ * A transaction never spans two scopes (FR-038): with one database file per
5
+ * scope, a cross-scope transaction is not expressible rather than merely
6
+ * discouraged.
7
+ */
8
+
9
+ import type { FilterNode, Query, ResultPage } from './query';
10
+ import type { RecordInput, StorableRecordData, StoredRecord, WriteOutcome } from './record';
11
+ import type { Attachments } from './adapter';
12
+
13
+ /** Collection operations, available on a handle and inside a transaction. */
14
+ export type CollectionOps<T extends StorableRecordData = StorableRecordData> = {
15
+ insert(record: RecordInput<T>): Promise<WriteOutcome>;
16
+ update(id: string, record: Partial<RecordInput<T>> & { data: T }): Promise<WriteOutcome>;
17
+ upsert(record: RecordInput<T>): Promise<WriteOutcome>;
18
+ /** Rejects with RECORD_NOT_FOUND when absent (FR-027). */
19
+ get(id: string): Promise<StoredRecord<T>>;
20
+ /** Null-returning variant, so absence stays distinguishable at the call site. */
21
+ find(id: string): Promise<StoredRecord<T> | null>;
22
+ delete(id: string): Promise<WriteOutcome>;
23
+ softDelete(id: string): Promise<WriteOutcome>;
24
+ restore(id: string): Promise<WriteOutcome>;
25
+ list(query?: Query): Promise<ResultPage<T>>;
26
+ count(filter?: FilterNode): Promise<number>;
27
+ };
28
+
29
+ export type Transaction = {
30
+ collection<T extends StorableRecordData = StorableRecordData>(name: string): CollectionOps<T>;
31
+ /**
32
+ * Attachments whose references join this transaction (FR-072): they roll
33
+ * back with it, and cascaded files are removed only once it commits. Added
34
+ * in 0.2.0 — the handle's own `attachments()` no longer reaches into an
35
+ * in-flight transaction.
36
+ */
37
+ attachments(): Attachments;
38
+ /** Discards every write in this transaction (FR-041). */
39
+ abort(): Promise<void>;
40
+ };
41
+
42
+ export type BatchOperation =
43
+ | { type: 'insert'; collection: string; record: RecordInput }
44
+ | { type: 'update'; collection: string; id: string; data: StorableRecordData }
45
+ | { type: 'upsert'; collection: string; record: RecordInput }
46
+ | { type: 'delete'; collection: string; id: string }
47
+ | { type: 'softDelete'; collection: string; id: string };
48
+
49
+ export type BatchOutcome = {
50
+ applied: number;
51
+ outcomes: WriteOutcome[];
52
+ };
@@ -1,144 +1,153 @@
1
- /**
2
- * The chunked AES-256-GCM artifact format.
3
- *
4
- * Whole-file encryption was rejected because a 100 MB artifact would have to
5
- * be held in memory twice — plaintext and ciphertext — which breaks FR-107a's
6
- * flat memory bound and every PERF-001 budget along with it (research.md R4).
7
- * Chunking keeps exactly one 256 KB chunk in flight regardless of artifact
8
- * size.
9
- *
10
- * ```text
11
- * [ header 22 bytes ] magic | format version | chunk size | chunk count | plaintext size
12
- * [ chunk 0 ] ciphertext(≤ chunkSize) | auth tag (16)
13
- * [ chunk 1 ] …
14
- * ```
15
- *
16
- * The auth tag follows each chunk rather than sitting in the header, which is
17
- * a deliberate departure from the sketch in storage-layout.md. A tag is only
18
- * known after its chunk has been encrypted, and the filesystem peer appends —
19
- * it cannot seek back to patch a header. Collecting every tag before writing
20
- * anything would mean buffering the whole ciphertext, which is the thing this
21
- * format exists to avoid. Inline tags keep the write append-only and leave
22
- * every chunk at a computable offset.
23
- *
24
- * A failing tag on read is `ATTACHMENT_UNDECRYPTABLE`, deliberately distinct
25
- * from `ATTACHMENT_MISSING` (FR-107c): one is a wrong key or a tampered file
26
- * and may be recoverable, the other is data that is simply gone.
27
- */
28
-
29
- import { Budgets } from '../contract/config';
30
- import { DiagnosticCodes } from '../contract/errors';
31
- import { raise } from '../diagnostics/errors';
32
- import { nonceForChunk } from './derive';
33
- import { cipherModule } from './provider';
34
-
35
- export const MAGIC = 'LDB1';
36
- export const FORMAT_VERSION = 1;
37
- export const HEADER_BYTES = 22;
38
- export const TAG_BYTES = 16;
39
- export const ALGORITHM = 'aes-256-gcm';
40
-
41
- export type ArtifactHeader = {
42
- formatVersion: number;
43
- chunkSize: number;
44
- chunkCount: number;
45
- plaintextSize: number;
46
- };
47
-
48
- export function chunkCountFor(plaintextSize: number, chunkSize: number): number {
49
- return plaintextSize === 0 ? 0 : Math.ceil(plaintextSize / chunkSize);
50
- }
51
-
52
- export function encodeHeader(header: ArtifactHeader): Buffer {
53
- const buffer = Buffer.alloc(HEADER_BYTES);
54
- buffer.write(MAGIC, 0, 4, 'ascii');
55
- buffer.writeUInt16BE(header.formatVersion, 4);
56
- buffer.writeUInt32BE(header.chunkSize, 6);
57
- buffer.writeUInt32BE(header.chunkCount, 10);
58
- // Split across two 32-bit writes: artifact sizes stay well inside the safe
59
- // integer range, and this avoids depending on BigInt in Hermes.
60
- buffer.writeUInt32BE(Math.floor(header.plaintextSize / 0x100000000), 14);
61
- buffer.writeUInt32BE(header.plaintextSize % 0x100000000, 18);
62
- return buffer;
63
- }
64
-
65
- export function decodeHeader(buffer: Buffer): ArtifactHeader {
66
- if (buffer.length < HEADER_BYTES || buffer.toString('ascii', 0, 4) !== MAGIC) {
67
- raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
68
- message: 'artifact header is not a recognised encrypted artifact',
69
- });
70
- }
71
- return {
72
- formatVersion: buffer.readUInt16BE(4),
73
- chunkSize: buffer.readUInt32BE(6),
74
- chunkCount: buffer.readUInt32BE(10),
75
- plaintextSize: buffer.readUInt32BE(14) * 0x100000000 + buffer.readUInt32BE(18),
76
- };
77
- }
78
-
79
- /** Plaintext length of chunk `index`; the last one is usually short. */
80
- export function chunkPlaintextLength(header: ArtifactHeader, index: number): number {
81
- const start = index * header.chunkSize;
82
- return Math.max(0, Math.min(header.chunkSize, header.plaintextSize - start));
83
- }
84
-
85
- /**
86
- * Byte offset of chunk `index` in the file.
87
- *
88
- * Every chunk but the last is full, so the offset is arithmetic rather than a
89
- * table lookup — which is what lets a range read seek straight to the chunk
90
- * it needs instead of walking the file.
91
- */
92
- export function chunkOffset(header: ArtifactHeader, index: number): number {
93
- return HEADER_BYTES + index * (header.chunkSize + TAG_BYTES);
94
- }
95
-
96
- export function encryptedLength(header: ArtifactHeader): number {
97
- if (header.chunkCount === 0) {
98
- return HEADER_BYTES;
99
- }
100
- const lastIndex = header.chunkCount - 1;
101
- return chunkOffset(header, lastIndex) + chunkPlaintextLength(header, lastIndex) + TAG_BYTES;
102
- }
103
-
104
- /** One chunk in, ciphertext with its tag appended out. */
105
- export function encryptChunk(key: Uint8Array, index: number, plaintext: Buffer): Buffer {
106
- const cipher = cipherModule().createCipheriv(ALGORITHM, key, nonceForChunk(index));
107
- const body = Buffer.concat([
108
- Buffer.from(cipher.update(plaintext)),
109
- Buffer.from(cipher.final()),
110
- ]);
111
- return Buffer.concat([body, Buffer.from(cipher.getAuthTag())]);
112
- }
113
-
114
- export function decryptChunk(key: Uint8Array, index: number, stored: Buffer): Buffer {
115
- if (stored.length < TAG_BYTES) {
116
- raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
117
- message: 'artifact chunk is truncated',
118
- });
119
- }
120
- const body = stored.subarray(0, stored.length - TAG_BYTES);
121
- const tag = stored.subarray(stored.length - TAG_BYTES);
122
-
123
- const decipher = cipherModule().createDecipheriv(ALGORITHM, key, nonceForChunk(index));
124
- decipher.setAuthTag(tag);
125
- try {
126
- return Buffer.concat([Buffer.from(decipher.update(body)), Buffer.from(decipher.final())]);
127
- } catch (error) {
128
- // A failing tag is the only signal that separates "exists but will not
129
- // decrypt" from "missing", and an app responds to them differently.
130
- raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
131
- message: 'artifact chunk failed authentication',
132
- cause: error,
133
- });
134
- }
135
- }
136
-
137
- export function headerFor(plaintextSize: number, chunkSize = Budgets.ARTIFACT_CHUNK_BYTES): ArtifactHeader {
138
- return {
139
- formatVersion: FORMAT_VERSION,
140
- chunkSize,
141
- chunkCount: chunkCountFor(plaintextSize, chunkSize),
142
- plaintextSize,
143
- };
144
- }
1
+ /**
2
+ * The chunked AES-256-GCM artifact format.
3
+ *
4
+ * Whole-file encryption was rejected because a 100 MB artifact would have to
5
+ * be held in memory twice — plaintext and ciphertext — which breaks FR-107a's
6
+ * flat memory bound and every PERF-001 budget along with it (research.md R4).
7
+ * Chunking keeps exactly one 256 KB chunk in flight regardless of artifact
8
+ * size.
9
+ *
10
+ * ```text
11
+ * [ header 22 bytes ] magic | format version | chunk size | chunk count | plaintext size
12
+ * [ chunk 0 ] ciphertext(≤ chunkSize) | auth tag (16)
13
+ * [ chunk 1 ] …
14
+ * ```
15
+ *
16
+ * The auth tag follows each chunk rather than sitting in the header, which is
17
+ * a deliberate departure from the sketch in storage-layout.md. A tag is only
18
+ * known after its chunk has been encrypted, and the filesystem peer appends —
19
+ * it cannot seek back to patch a header. Collecting every tag before writing
20
+ * anything would mean buffering the whole ciphertext, which is the thing this
21
+ * format exists to avoid. Inline tags keep the write append-only and leave
22
+ * every chunk at a computable offset.
23
+ *
24
+ * A failing tag on read is `ATTACHMENT_UNDECRYPTABLE`, deliberately distinct
25
+ * from `ATTACHMENT_MISSING` (FR-107c): one is a wrong key or a tampered file
26
+ * and may be recoverable, the other is data that is simply gone.
27
+ */
28
+
29
+ import { Budgets } from '../contract/config';
30
+ import { DiagnosticCodes } from '../contract/errors';
31
+ import { raise } from '../diagnostics/errors';
32
+ import { nonceForChunk } from './derive';
33
+ import { cipherModule } from './provider';
34
+ import { asciiAt, concatBytes } from '../platform/bytes';
35
+
36
+ export const MAGIC = 'LDB1';
37
+ export const FORMAT_VERSION = 1;
38
+ export const HEADER_BYTES = 22;
39
+ export const TAG_BYTES = 16;
40
+ export const ALGORITHM = 'aes-256-gcm';
41
+
42
+ export type ArtifactHeader = {
43
+ formatVersion: number;
44
+ chunkSize: number;
45
+ chunkCount: number;
46
+ plaintextSize: number;
47
+ };
48
+
49
+ export function chunkCountFor(plaintextSize: number, chunkSize: number): number {
50
+ return plaintextSize === 0 ? 0 : Math.ceil(plaintextSize / chunkSize);
51
+ }
52
+
53
+ export function encodeHeader(header: ArtifactHeader): Uint8Array {
54
+ const bytes = new Uint8Array(HEADER_BYTES);
55
+ for (let index = 0; index < 4; index += 1) {
56
+ bytes[index] = MAGIC.charCodeAt(index);
57
+ }
58
+ // Big-endian, as DataView writes by default; the on-disk format is unchanged.
59
+ const view = new DataView(bytes.buffer);
60
+ view.setUint16(4, header.formatVersion);
61
+ view.setUint32(6, header.chunkSize);
62
+ view.setUint32(10, header.chunkCount);
63
+ // Split across two 32-bit writes: artifact sizes stay well inside the safe
64
+ // integer range, and this avoids depending on BigInt in Hermes.
65
+ view.setUint32(14, Math.floor(header.plaintextSize / 0x100000000));
66
+ view.setUint32(18, header.plaintextSize % 0x100000000);
67
+ return bytes;
68
+ }
69
+
70
+ export function decodeHeader(bytes: Uint8Array): ArtifactHeader {
71
+ if (bytes.length < HEADER_BYTES || asciiAt(bytes, 0, 4) !== MAGIC) {
72
+ raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
73
+ message: 'artifact header is not a recognised encrypted artifact',
74
+ });
75
+ }
76
+ const view = new DataView(bytes.buffer, bytes.byteOffset, HEADER_BYTES);
77
+ return {
78
+ formatVersion: view.getUint16(4),
79
+ chunkSize: view.getUint32(6),
80
+ chunkCount: view.getUint32(10),
81
+ plaintextSize: view.getUint32(14) * 0x100000000 + view.getUint32(18),
82
+ };
83
+ }
84
+
85
+ /** Plaintext length of chunk `index`; the last one is usually short. */
86
+ export function chunkPlaintextLength(header: ArtifactHeader, index: number): number {
87
+ const start = index * header.chunkSize;
88
+ return Math.max(0, Math.min(header.chunkSize, header.plaintextSize - start));
89
+ }
90
+
91
+ /**
92
+ * Byte offset of chunk `index` in the file.
93
+ *
94
+ * Every chunk but the last is full, so the offset is arithmetic rather than a
95
+ * table lookup — which is what lets a range read seek straight to the chunk
96
+ * it needs instead of walking the file.
97
+ */
98
+ export function chunkOffset(header: ArtifactHeader, index: number): number {
99
+ return HEADER_BYTES + index * (header.chunkSize + TAG_BYTES);
100
+ }
101
+
102
+ export function encryptedLength(header: ArtifactHeader): number {
103
+ if (header.chunkCount === 0) {
104
+ return HEADER_BYTES;
105
+ }
106
+ const lastIndex = header.chunkCount - 1;
107
+ return chunkOffset(header, lastIndex) + chunkPlaintextLength(header, lastIndex) + TAG_BYTES;
108
+ }
109
+
110
+ /** One chunk in, ciphertext with its tag appended out. */
111
+ export function encryptChunk(key: Uint8Array, index: number, plaintext: Uint8Array): Uint8Array {
112
+ const cipher = cipherModule().createCipheriv(ALGORITHM, key, nonceForChunk(index));
113
+ return concatBytes([
114
+ new Uint8Array(cipher.update(plaintext)),
115
+ new Uint8Array(cipher.final()),
116
+ new Uint8Array(cipher.getAuthTag()),
117
+ ]);
118
+ }
119
+
120
+ export function decryptChunk(key: Uint8Array, index: number, stored: Uint8Array): Uint8Array {
121
+ if (stored.length < TAG_BYTES) {
122
+ raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
123
+ message: 'artifact chunk is truncated',
124
+ });
125
+ }
126
+ const body = stored.subarray(0, stored.length - TAG_BYTES);
127
+ const tag = stored.subarray(stored.length - TAG_BYTES);
128
+
129
+ const decipher = cipherModule().createDecipheriv(ALGORITHM, key, nonceForChunk(index));
130
+ decipher.setAuthTag(tag);
131
+ try {
132
+ return concatBytes([new Uint8Array(decipher.update(body)), new Uint8Array(decipher.final())]);
133
+ } catch (error) {
134
+ // A failing tag is the only signal that separates "exists but will not
135
+ // decrypt" from "missing", and an app responds to them differently.
136
+ raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
137
+ message: 'artifact chunk failed authentication',
138
+ cause: error,
139
+ });
140
+ }
141
+ }
142
+
143
+ export function headerFor(
144
+ plaintextSize: number,
145
+ chunkSize = Budgets.ARTIFACT_CHUNK_BYTES,
146
+ ): ArtifactHeader {
147
+ return {
148
+ formatVersion: FORMAT_VERSION,
149
+ chunkSize,
150
+ chunkCount: chunkCountFor(plaintextSize, chunkSize),
151
+ plaintextSize,
152
+ };
153
+ }
@@ -1,63 +1,64 @@
1
- /**
2
- * Per-artifact key derivation.
3
- *
4
- * The database key is handed straight to SQLCipher. Using that same material
5
- * as the AES-GCM key for artifacts would mean the two keystreams share a
6
- * secret, so a weakness in either one — a nonce collision in the artifact
7
- * format, a padding oracle in the database format — becomes a weakness in
8
- * both. HKDF costs one hash and removes the coupling entirely (research.md
9
- * R4).
10
- *
11
- * The artifact id is the HKDF `info`, so two artifacts under the same
12
- * database key derive different subkeys. That is what makes the deterministic
13
- * nonce scheme safe: a nonce is only ever reused with a different key.
14
- */
15
-
16
- import { cipherModule } from './provider';
17
-
18
- const HKDF_DIGEST = 'sha256';
19
- const KEY_BYTES = 32;
20
- const NONCE_BYTES = 12;
21
-
22
- function bytesOf(value: string): Uint8Array {
23
- return Uint8Array.from(Buffer.from(value, 'utf8'));
24
- }
25
-
26
- /** 32 bytes, one artifact, one database key. */
27
- export function deriveArtifactKey(
28
- databaseKey: string,
29
- scopeKey: string,
30
- attachmentId: string,
31
- ): Uint8Array {
32
- const derived = cipherModule().hkdfSync(
33
- HKDF_DIGEST,
34
- bytesOf(databaseKey),
35
- // The scope digest as salt keeps two scopes holding an artifact under
36
- // the same id from deriving the same subkey.
37
- bytesOf(scopeKey),
38
- bytesOf(`chipmobilesdk-localdb/artifact/${attachmentId}`),
39
- KEY_BYTES,
40
- );
41
- return new Uint8Array(derived);
42
- }
43
-
44
- /**
45
- * Deterministic 12-byte nonce from the chunk index.
46
- *
47
- * Deterministic rather than random because a random nonce would have to be
48
- * stored per chunk, and because uniqueness is already guaranteed one level
49
- * up: the key is unique per artifact, so an index that is unique within the
50
- * artifact is unique under that key. The remaining four bytes are a fixed
51
- * domain tag rather than padding, so a nonce from this scheme can never
52
- * collide with one from a future scheme that uses the same counter space.
53
- */
54
- export function nonceForChunk(index: number): Uint8Array {
55
- const nonce = new Uint8Array(NONCE_BYTES);
56
- const view = new DataView(nonce.buffer);
57
- view.setUint32(0, 0x4c444231, false); // "LDB1"
58
- // Split across two 32-bit writes rather than a BigInt: chunk indexes stay
59
- // far inside the safe integer range, and Hermes is happier without one.
60
- view.setUint32(4, Math.floor(index / 0x100000000), false);
61
- view.setUint32(8, index % 0x100000000, false);
62
- return nonce;
63
- }
1
+ /**
2
+ * Per-artifact key derivation.
3
+ *
4
+ * The database key is handed straight to SQLCipher. Using that same material
5
+ * as the AES-GCM key for artifacts would mean the two keystreams share a
6
+ * secret, so a weakness in either one — a nonce collision in the artifact
7
+ * format, a padding oracle in the database format — becomes a weakness in
8
+ * both. HKDF costs one hash and removes the coupling entirely (research.md
9
+ * R4).
10
+ *
11
+ * The artifact id is the HKDF `info`, so two artifacts under the same
12
+ * database key derive different subkeys. That is what makes the deterministic
13
+ * nonce scheme safe: a nonce is only ever reused with a different key.
14
+ */
15
+
16
+ import { cipherModule } from './provider';
17
+ import { utf8Bytes } from '../platform/bytes';
18
+
19
+ const HKDF_DIGEST = 'sha256';
20
+ const KEY_BYTES = 32;
21
+ const NONCE_BYTES = 12;
22
+
23
+ function bytesOf(value: string): Uint8Array {
24
+ return utf8Bytes(value);
25
+ }
26
+
27
+ /** 32 bytes, one artifact, one database key. */
28
+ export function deriveArtifactKey(
29
+ databaseKey: string,
30
+ scopeKey: string,
31
+ attachmentId: string,
32
+ ): Uint8Array {
33
+ const derived = cipherModule().hkdfSync(
34
+ HKDF_DIGEST,
35
+ bytesOf(databaseKey),
36
+ // The scope digest as salt keeps two scopes holding an artifact under
37
+ // the same id from deriving the same subkey.
38
+ bytesOf(scopeKey),
39
+ bytesOf(`chipmobilesdk-localdb/artifact/${attachmentId}`),
40
+ KEY_BYTES,
41
+ );
42
+ return new Uint8Array(derived);
43
+ }
44
+
45
+ /**
46
+ * Deterministic 12-byte nonce from the chunk index.
47
+ *
48
+ * Deterministic rather than random because a random nonce would have to be
49
+ * stored per chunk, and because uniqueness is already guaranteed one level
50
+ * up: the key is unique per artifact, so an index that is unique within the
51
+ * artifact is unique under that key. The remaining four bytes are a fixed
52
+ * domain tag rather than padding, so a nonce from this scheme can never
53
+ * collide with one from a future scheme that uses the same counter space.
54
+ */
55
+ export function nonceForChunk(index: number): Uint8Array {
56
+ const nonce = new Uint8Array(NONCE_BYTES);
57
+ const view = new DataView(nonce.buffer);
58
+ view.setUint32(0, 0x4c444231, false); // "LDB1"
59
+ // Split across two 32-bit writes rather than a BigInt: chunk indexes stay
60
+ // far inside the safe integer range, and Hermes is happier without one.
61
+ view.setUint32(4, Math.floor(index / 0x100000000), false);
62
+ view.setUint32(8, index % 0x100000000, false);
63
+ return nonce;
64
+ }