sealkeep 0.5.0

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 (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,188 @@
1
+ import { gzipSync, gunzipSync } from "node:zlib";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
+ import { aeadCipher, aeadDecipher } from "./aead.js";
4
+ import { CryptoError, unwrapArchiveKey, wrapForKey, wrapForPhrase, x25519PublicKeyFromRaw, zeroize } from "./recipients.js";
5
+ import { DEFAULT_CHUNK_BYTES, ENVELOPE_VERSION, KEY_BYTES, NONCE_BYTES, SUITES, TAG_BYTES, chunkAad, chunkNonce } from "./format.js";
6
+ const sha256 = (input) => createHash("sha256").update(input).digest("hex");
7
+ function toPublicKey(value) {
8
+ return Buffer.isBuffer(value) ? x25519PublicKeyFromRaw(value) : value;
9
+ }
10
+ /** Exported so `stream.ts` produces byte-for-byte the same wraps as the buffered seal. */
11
+ export function wrapAll(archiveKey, recipients, suite, archiveId, seed) {
12
+ if (recipients.length === 0)
13
+ throw new CryptoError("no_recipient", "An archive needs at least one recipient");
14
+ return recipients.map((recipient, index) => {
15
+ const nonce = seed?.wrapNonces[index] ?? randomBytes(NONCE_BYTES);
16
+ if (recipient.kind === "phrase") {
17
+ return wrapForPhrase(archiveKey, recipient.phrase, suite, archiveId, { salt: seed?.wrapSalts[index] ?? randomBytes(16), nonce, params: recipient.scrypt });
18
+ }
19
+ return wrapForKey(archiveKey, toPublicKey(recipient.publicKey), suite, archiveId, { nonce, ephemeralPrivateKey: seed?.ephemeralPrivateKeys?.[index] });
20
+ });
21
+ }
22
+ /**
23
+ * Encrypts one archive.
24
+ *
25
+ * A random per-archive key encrypts the plaintext in chunks; that key is then
26
+ * wrapped separately for each recipient. Adding or removing a recipient therefore
27
+ * never requires re-encrypting the data.
28
+ */
29
+ export function encryptArchive(plaintext, options) {
30
+ const suite = options.suite ?? "chacha20-poly1305";
31
+ if (!SUITES.includes(suite))
32
+ throw new CryptoError("suite_unsupported", `Unsupported suite: ${suite}`);
33
+ const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;
34
+ if (!Number.isInteger(chunkBytes) || chunkBytes <= 0)
35
+ throw new CryptoError("invalid_chunk_size", "chunkBytes must be a positive integer");
36
+ const archiveId = options.archiveId ?? randomUUID();
37
+ const archiveKey = options.deterministic?.archiveKey ?? randomBytes(KEY_BYTES);
38
+ const noncePrefix = options.deterministic?.noncePrefix ?? randomBytes(4);
39
+ if (archiveKey.length !== KEY_BYTES)
40
+ throw new CryptoError("key_malformed", `An archive key must be ${KEY_BYTES} bytes`);
41
+ if ((options.compression ?? "none") === "gzip-chunk")
42
+ throw new CryptoError("envelope_malformed", "gzip-chunk sealing is streaming-only; use sealArchiveToFile");
43
+ const body = (options.compression ?? "none") === "gzip" ? gzipSync(plaintext, { level: 6 }) : plaintext;
44
+ const totalChunks = Math.max(1, Math.ceil(body.length / chunkBytes));
45
+ const chunks = [];
46
+ const blocks = [];
47
+ try {
48
+ for (let index = 0; index < totalChunks; index += 1) {
49
+ const slice = body.subarray(index * chunkBytes, Math.min((index + 1) * chunkBytes, body.length));
50
+ const final = index === totalChunks - 1;
51
+ const nonce = chunkNonce(noncePrefix, index);
52
+ const cipher = aeadCipher(suite, archiveKey, nonce);
53
+ cipher.setAAD(chunkAad({ archiveId, suite, index, totalChunks, plaintextBytes: slice.length, final }));
54
+ const stored = Buffer.concat([cipher.update(slice), cipher.final(), cipher.getAuthTag()]);
55
+ blocks.push(stored);
56
+ chunks.push({ index, plaintextBytes: slice.length, storedBytes: stored.length, sha256: sha256(stored), nonce: nonce.toString("base64"), final });
57
+ }
58
+ const envelope = {
59
+ version: ENVELOPE_VERSION, archiveId, suite, chunkBytes, chunks,
60
+ manifest: { plaintextSha256: sha256(body), plaintextBytes: body.length,
61
+ ...((options.compression ?? "none") === "gzip"
62
+ ? { compression: "gzip", originalBytes: plaintext.length, originalSha256: sha256(plaintext) }
63
+ : {}), createdAt: options.createdAt ?? new Date().toISOString(), adapter: options.adapter },
64
+ wrappedKeys: wrapAll(archiveKey, options.recipients, suite, archiveId, options.deterministic)
65
+ };
66
+ return { envelope, ciphertext: Buffer.concat(blocks) };
67
+ }
68
+ finally {
69
+ if (!options.deterministic)
70
+ zeroize(archiveKey);
71
+ }
72
+ }
73
+ /**
74
+ * Structural checks that must pass before any key is used.
75
+ *
76
+ * Takes a length rather than the bytes so the streaming reader, which never
77
+ * holds the ciphertext, is gated by exactly the same rules.
78
+ */
79
+ export function validateEnvelope(envelope, ciphertextBytes) {
80
+ if (envelope.version !== ENVELOPE_VERSION)
81
+ throw new CryptoError("version_unsupported", `Envelope version ${String(envelope.version)} is not supported`);
82
+ if (!SUITES.includes(envelope.suite))
83
+ throw new CryptoError("suite_unsupported", `Unsupported suite: ${String(envelope.suite)}`);
84
+ if (!Array.isArray(envelope.chunks) || envelope.chunks.length === 0)
85
+ throw new CryptoError("envelope_malformed", "An envelope must describe at least one chunk");
86
+ let stored = 0;
87
+ for (const [position, chunk] of envelope.chunks.entries()) {
88
+ if (chunk.index !== position)
89
+ throw new CryptoError("chunk_out_of_order", `Chunk ${position} claims index ${chunk.index}`);
90
+ if (chunk.storedBytes <= TAG_BYTES - 1 || chunk.storedBytes !== chunk.plaintextBytes + TAG_BYTES)
91
+ throw new CryptoError("envelope_malformed", `Chunk ${position} has an impossible length`);
92
+ if (chunk.final !== (position === envelope.chunks.length - 1))
93
+ throw new CryptoError("chunk_truncated", "Exactly the last chunk may be marked final");
94
+ stored += chunk.storedBytes;
95
+ }
96
+ if (stored !== ciphertextBytes)
97
+ throw new CryptoError("ciphertext_length_mismatch", `Envelope describes ${stored} stored bytes; ${ciphertextBytes} supplied`);
98
+ const declared = envelope.chunks.reduce((total, chunk) => total + chunk.plaintextBytes, 0);
99
+ if (declared !== envelope.manifest.plaintextBytes)
100
+ throw new CryptoError("envelope_malformed", "Chunk lengths do not sum to the manifest plaintext length");
101
+ }
102
+ /**
103
+ * Decrypts an archive after verifying its structure and every stored chunk hash.
104
+ * Every chunk is authenticated against associated data that pins its position and
105
+ * the total chunk count, so a reordered or truncated archive fails rather than
106
+ * returning partial plaintext.
107
+ */
108
+ export function decryptArchive(envelope, ciphertext, unlock) {
109
+ validateEnvelope(envelope, ciphertext.length);
110
+ const archiveKey = unwrapArchiveKey(envelope.wrappedKeys, envelope.suite, envelope.archiveId, unlock);
111
+ if (archiveKey.length !== KEY_BYTES) {
112
+ zeroize(archiveKey);
113
+ throw new CryptoError("key_malformed", "Unwrapped archive key has the wrong length");
114
+ }
115
+ try {
116
+ const parts = [];
117
+ let offset = 0;
118
+ for (const chunk of envelope.chunks) {
119
+ const stored = ciphertext.subarray(offset, offset + chunk.storedBytes);
120
+ offset += chunk.storedBytes;
121
+ if (sha256(stored) !== chunk.sha256)
122
+ throw new CryptoError("chunk_hash_mismatch", `Chunk ${chunk.index} does not match its recorded hash`);
123
+ const decipher = aeadDecipher(envelope.suite, archiveKey, Buffer.from(chunk.nonce, "base64"));
124
+ decipher.setAAD(chunkAad({ archiveId: envelope.archiveId, suite: envelope.suite, index: chunk.index, totalChunks: envelope.chunks.length, plaintextBytes: chunk.plaintextBytes, final: chunk.final }));
125
+ decipher.setAuthTag(stored.subarray(stored.length - TAG_BYTES));
126
+ try {
127
+ parts.push(Buffer.concat([decipher.update(stored.subarray(0, stored.length - TAG_BYTES)), decipher.final()]));
128
+ }
129
+ catch {
130
+ throw new CryptoError("chunk_auth_failed", `Chunk ${chunk.index} failed authentication`);
131
+ }
132
+ }
133
+ const plaintext = Buffer.concat(parts);
134
+ if (plaintext.length !== envelope.manifest.plaintextBytes)
135
+ throw new CryptoError("plaintext_length_mismatch", "Decrypted length does not match the manifest");
136
+ if (sha256(plaintext) !== envelope.manifest.plaintextSha256)
137
+ throw new CryptoError("plaintext_hash_mismatch", "Decrypted bytes do not match the manifest hash");
138
+ // Absent on archives written without compression, where the authenticated
139
+ // body already is the caller's plaintext.
140
+ if (envelope.manifest.compression === undefined)
141
+ return plaintext;
142
+ if (envelope.manifest.compression !== "gzip" && envelope.manifest.compression !== "gzip-chunk")
143
+ throw new CryptoError("envelope_malformed", "Unsupported compression");
144
+ let original;
145
+ try {
146
+ original = envelope.manifest.compression === "gzip-chunk"
147
+ // Each part is one chunk's compressed slice, independently made and
148
+ // independently undone — concatenating the results reproduces the file.
149
+ ? Buffer.concat(parts.map((part) => gunzipSync(part)))
150
+ : gunzipSync(plaintext);
151
+ }
152
+ catch {
153
+ throw new CryptoError("plaintext_hash_mismatch", "Decompression failed after authentication");
154
+ }
155
+ // The archive is honoured only once the caller's own bytes are proven, not
156
+ // merely the compressed stream they were derived from.
157
+ if (original.length !== envelope.manifest.originalBytes)
158
+ throw new CryptoError("plaintext_length_mismatch", "Decompressed length does not match the manifest");
159
+ if (sha256(original) !== envelope.manifest.originalSha256)
160
+ throw new CryptoError("plaintext_hash_mismatch", "Decompressed bytes do not match the manifest hash");
161
+ return original;
162
+ }
163
+ finally {
164
+ zeroize(archiveKey);
165
+ }
166
+ }
167
+ /**
168
+ * Rotates the recipient set without touching ciphertext. `replace` is how a lost
169
+ * device is cut off; `add` is how a new device or extra recovery key is granted.
170
+ */
171
+ export function rewrapArchive(envelope, unlock, recipients, options = {}) {
172
+ const archiveKey = unwrapArchiveKey(envelope.wrappedKeys, envelope.suite, envelope.archiveId, unlock);
173
+ try {
174
+ const wrapped = wrapAll(archiveKey, recipients, envelope.suite, envelope.archiveId);
175
+ const merged = options.mode === "add" ? [...envelope.wrappedKeys, ...wrapped.filter((next) => !envelope.wrappedKeys.some((existing) => existing.id === next.id))] : wrapped;
176
+ return { ...envelope, wrappedKeys: merged };
177
+ }
178
+ finally {
179
+ zeroize(archiveKey);
180
+ }
181
+ }
182
+ /** Recipient identifiers present on an archive. Useful for auditing who can open it. */
183
+ export function recipientIds(envelope) {
184
+ return {
185
+ phrase: envelope.wrappedKeys.filter((key) => key.type === "phrase").map((key) => key.id),
186
+ keys: envelope.wrappedKeys.filter((key) => key.type === "x25519").map((key) => key.id)
187
+ };
188
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Sealkeep archive envelope, format version 2.
3
+ *
4
+ * The format is documented here rather than in prose elsewhere: an implementation
5
+ * that matches these types and the associated-data construction below can read and
6
+ * write Sealkeep archives without reference to the application code.
7
+ */
8
+ export declare const ENVELOPE_VERSION: 2;
9
+ export declare const DEFAULT_CHUNK_BYTES: number;
10
+ /** Both suites are AEADs provided by OpenSSL. No protocol is hand-rolled on top. */
11
+ export type Suite = "chacha20-poly1305" | "aes-256-gcm";
12
+ export declare const SUITES: readonly Suite[];
13
+ export declare const KEY_BYTES = 32;
14
+ export declare const NONCE_BYTES = 12;
15
+ export declare const TAG_BYTES = 16;
16
+ export type ScryptParams = {
17
+ N: number;
18
+ r: number;
19
+ p: number;
20
+ };
21
+ export declare const DEFAULT_SCRYPT: ScryptParams;
22
+ /** A recipient derived from a human recovery phrase. */
23
+ export type PhraseRecipient = {
24
+ type: "phrase";
25
+ id: string;
26
+ kdf: {
27
+ algorithm: "scrypt";
28
+ salt: string;
29
+ params: ScryptParams;
30
+ };
31
+ nonce: string;
32
+ ciphertext: string;
33
+ };
34
+ /** A recipient identified by an X25519 public key: a device, or an extra recovery key. */
35
+ export type KeyRecipient = {
36
+ type: "x25519";
37
+ id: string;
38
+ recipientPublicKey: string;
39
+ ephemeralPublicKey: string;
40
+ nonce: string;
41
+ ciphertext: string;
42
+ };
43
+ export type WrappedKey = PhraseRecipient | KeyRecipient;
44
+ export type ChunkHeader = {
45
+ index: number;
46
+ /** Bytes this chunk's ciphertext decrypts to. Under "gzip-chunk" that is the compressed slice; the raw length lives in `rawBytes`. */
47
+ plaintextBytes: number;
48
+ /** Original (pre-compression) bytes this chunk covers. Present only under "gzip-chunk", where it is what makes byte-ranged retrieval computable without decompressing anything. */
49
+ rawBytes?: number;
50
+ /** Stored bytes for this chunk: ciphertext followed by its authentication tag. */
51
+ storedBytes: number;
52
+ sha256: string;
53
+ nonce: string;
54
+ final: boolean;
55
+ };
56
+ export type Envelope = {
57
+ version: typeof ENVELOPE_VERSION;
58
+ archiveId: string;
59
+ suite: Suite;
60
+ chunkBytes: number;
61
+ chunks: ChunkHeader[];
62
+ manifest: {
63
+ /**
64
+ * "gzip": applied to the whole plaintext before chunking — the legacy
65
+ * layout, still read forever. "gzip-chunk": applied inside each chunk, so
66
+ * every chunk decrypts AND decompresses on its own — what makes streaming
67
+ * seals and fetch-only-the-chunks retrieval possible. Absent means none.
68
+ */
69
+ compression?: "gzip" | "gzip-chunk";
70
+ originalBytes?: number;
71
+ originalSha256?: string;
72
+ plaintextSha256: string;
73
+ plaintextBytes: number;
74
+ createdAt: string;
75
+ adapter?: {
76
+ agent: string;
77
+ version: string;
78
+ };
79
+ };
80
+ wrappedKeys: WrappedKey[];
81
+ };
82
+ /**
83
+ * Associated data bound to every chunk. It commits to the format version, the
84
+ * suite, the archive identity, this chunk's position, the total chunk count, the
85
+ * chunk's plaintext length, and whether it terminates the archive.
86
+ *
87
+ * That set is what makes reordering, truncating, splicing between archives, and
88
+ * suite downgrade detectable: any of those changes produces different associated
89
+ * data, and the AEAD tag then fails.
90
+ */
91
+ export declare function chunkAad(input: {
92
+ archiveId: string;
93
+ suite: Suite;
94
+ index: number;
95
+ totalChunks: number;
96
+ plaintextBytes: number;
97
+ final: boolean;
98
+ }): Buffer;
99
+ /** Associated data for a wrapped archive key, binding the wrap to one archive and one recipient. */
100
+ export declare function wrapAad(archiveId: string, recipientId: string): Buffer;
101
+ /**
102
+ * Chunk nonces are a 4-byte per-archive random prefix followed by a big-endian
103
+ * 64-bit chunk counter. The archive key is random per archive, so a nonce can
104
+ * only repeat under a key if the same archive key is generated twice.
105
+ */
106
+ export declare function chunkNonce(prefix: Buffer, index: number): Buffer;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Sealkeep archive envelope, format version 2.
3
+ *
4
+ * The format is documented here rather than in prose elsewhere: an implementation
5
+ * that matches these types and the associated-data construction below can read and
6
+ * write Sealkeep archives without reference to the application code.
7
+ */
8
+ export const ENVELOPE_VERSION = 2;
9
+ export const DEFAULT_CHUNK_BYTES = 4 * 1024 * 1024;
10
+ export const SUITES = ["chacha20-poly1305", "aes-256-gcm"];
11
+ export const KEY_BYTES = 32;
12
+ export const NONCE_BYTES = 12;
13
+ export const TAG_BYTES = 16;
14
+ export const DEFAULT_SCRYPT = { N: 1 << 15, r: 8, p: 1 };
15
+ /**
16
+ * Associated data bound to every chunk. It commits to the format version, the
17
+ * suite, the archive identity, this chunk's position, the total chunk count, the
18
+ * chunk's plaintext length, and whether it terminates the archive.
19
+ *
20
+ * That set is what makes reordering, truncating, splicing between archives, and
21
+ * suite downgrade detectable: any of those changes produces different associated
22
+ * data, and the AEAD tag then fails.
23
+ */
24
+ export function chunkAad(input) {
25
+ return Buffer.from(`vaultline-chunk:v${ENVELOPE_VERSION}|${input.suite}|${input.archiveId}|${input.index}|${input.totalChunks}|${input.plaintextBytes}|${input.final ? 1 : 0}`, "utf8");
26
+ }
27
+ /** Associated data for a wrapped archive key, binding the wrap to one archive and one recipient. */
28
+ export function wrapAad(archiveId, recipientId) {
29
+ return Buffer.from(`vaultline-wrap:v${ENVELOPE_VERSION}|${archiveId}|${recipientId}`, "utf8");
30
+ }
31
+ /**
32
+ * Chunk nonces are a 4-byte per-archive random prefix followed by a big-endian
33
+ * 64-bit chunk counter. The archive key is random per archive, so a nonce can
34
+ * only repeat under a key if the same archive key is generated twice.
35
+ */
36
+ export function chunkNonce(prefix, index) {
37
+ if (prefix.length !== 4)
38
+ throw new Error("Nonce prefix must be 4 bytes");
39
+ const nonce = Buffer.alloc(NONCE_BYTES);
40
+ prefix.copy(nonce, 0);
41
+ nonce.writeBigUInt64BE(BigInt(index), 4);
42
+ return nonce;
43
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./format.js";
2
+ export * from "./recipients.js";
3
+ export * from "./envelope.js";
4
+ export * from "./stream.js";
5
+ export * from "./sha256-stream.js";
@@ -0,0 +1,5 @@
1
+ export * from "./format.js";
2
+ export * from "./recipients.js";
3
+ export * from "./envelope.js";
4
+ export * from "./stream.js";
5
+ export * from "./sha256-stream.js";
@@ -0,0 +1,42 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ import { type KeyRecipient, type PhraseRecipient, type ScryptParams, type Suite, type WrappedKey } from "./format.js";
3
+ export declare class CryptoError extends Error {
4
+ readonly code: string;
5
+ constructor(code: string, message: string);
6
+ }
7
+ /** Overwrites key material in place. Best effort: it cannot reach copies the runtime made. */
8
+ export declare function zeroize(...buffers: Buffer[]): void;
9
+ export declare function derivePhraseKey(phrase: string, salt: Buffer, params?: ScryptParams): Buffer;
10
+ declare function sealKey(suite: Suite, kek: Buffer, nonce: Buffer, archiveKey: Buffer, aad: Buffer): Buffer;
11
+ declare function openKey(suite: Suite, kek: Buffer, nonce: Buffer, sealed: Buffer, aad: Buffer): Buffer;
12
+ export declare function phraseRecipientId(salt: Buffer): string;
13
+ export declare function keyRecipientId(publicKeyRaw: Buffer): string;
14
+ export declare function rawPublicKey(key: KeyObject): Buffer;
15
+ export declare function wrapForPhrase(archiveKey: Buffer, phrase: string, suite: Suite, archiveId: string, options: {
16
+ salt: Buffer;
17
+ nonce: Buffer;
18
+ params?: ScryptParams;
19
+ }): PhraseRecipient;
20
+ /**
21
+ * Ephemeral-static X25519. A fresh ephemeral key per wrap means the shared secret,
22
+ * and therefore the key-encryption key, is unique to this archive and recipient.
23
+ */
24
+ export declare function wrapForKey(archiveKey: Buffer, recipientPublicKey: KeyObject, suite: Suite, archiveId: string, options: {
25
+ nonce: Buffer;
26
+ ephemeralPrivateKey?: KeyObject;
27
+ }): KeyRecipient;
28
+ export declare function x25519PrivateKeyFromRaw(raw: Buffer): KeyObject;
29
+ export declare function x25519PublicKeyFromRaw(raw: Buffer): KeyObject;
30
+ export type Unlock = {
31
+ phrase: string;
32
+ } | {
33
+ privateKey: KeyObject | string;
34
+ };
35
+ /** Tries every recipient this unlock method could open, and reports a single failure if none do. */
36
+ export declare function unwrapArchiveKey(wrappedKeys: readonly WrappedKey[], suite: Suite, archiveId: string, unlock: Unlock): Buffer;
37
+ export declare const wrapInternals: {
38
+ sealKey: typeof sealKey;
39
+ openKey: typeof openKey;
40
+ NONCE_BYTES: number;
41
+ };
42
+ export {};
@@ -0,0 +1,129 @@
1
+ import { createHash, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, scryptSync, timingSafeEqual } from "node:crypto";
2
+ import { aeadCipher, aeadDecipher } from "./aead.js";
3
+ import { DEFAULT_SCRYPT, KEY_BYTES, NONCE_BYTES, TAG_BYTES, wrapAad } from "./format.js";
4
+ export class CryptoError extends Error {
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = "CryptoError";
10
+ }
11
+ }
12
+ const b64 = (value) => value.toString("base64");
13
+ const unb64 = (value) => Buffer.from(value, "base64");
14
+ /** Overwrites key material in place. Best effort: it cannot reach copies the runtime made. */
15
+ export function zeroize(...buffers) {
16
+ for (const buffer of buffers)
17
+ buffer.fill(0);
18
+ }
19
+ export function derivePhraseKey(phrase, salt, params = DEFAULT_SCRYPT) {
20
+ return scryptSync(phrase.normalize("NFKD"), salt, KEY_BYTES, { ...params, maxmem: 256 * 1024 * 1024 });
21
+ }
22
+ function sealKey(suite, kek, nonce, archiveKey, aad) {
23
+ const cipher = aeadCipher(suite, kek, nonce);
24
+ cipher.setAAD(aad);
25
+ return Buffer.concat([cipher.update(archiveKey), cipher.final(), cipher.getAuthTag()]);
26
+ }
27
+ function openKey(suite, kek, nonce, sealed, aad) {
28
+ if (sealed.length <= TAG_BYTES)
29
+ throw new CryptoError("wrap_malformed", "Wrapped key is too short to contain a tag");
30
+ const decipher = aeadDecipher(suite, kek, nonce);
31
+ decipher.setAAD(aad);
32
+ decipher.setAuthTag(sealed.subarray(sealed.length - TAG_BYTES));
33
+ try {
34
+ return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]);
35
+ }
36
+ catch {
37
+ throw new CryptoError("unwrap_failed", "Wrapped archive key failed authentication");
38
+ }
39
+ }
40
+ export function phraseRecipientId(salt) {
41
+ return createHash("sha256").update(Buffer.concat([Buffer.from("vaultline-phrase-recipient:v2"), salt])).digest("hex").slice(0, 32);
42
+ }
43
+ export function keyRecipientId(publicKeyRaw) {
44
+ return createHash("sha256").update(Buffer.concat([Buffer.from("vaultline-key-recipient:v2"), publicKeyRaw])).digest("hex").slice(0, 32);
45
+ }
46
+ export function rawPublicKey(key) {
47
+ // The last 32 bytes of an X25519 SPKI DER encoding are the raw public key.
48
+ return key.export({ type: "spki", format: "der" }).subarray(-32);
49
+ }
50
+ export function wrapForPhrase(archiveKey, phrase, suite, archiveId, options) {
51
+ const params = options.params ?? DEFAULT_SCRYPT;
52
+ const id = phraseRecipientId(options.salt);
53
+ const kek = derivePhraseKey(phrase, options.salt, params);
54
+ try {
55
+ return { type: "phrase", id, kdf: { algorithm: "scrypt", salt: b64(options.salt), params }, nonce: b64(options.nonce), ciphertext: b64(sealKey(suite, kek, options.nonce, archiveKey, wrapAad(archiveId, id))) };
56
+ }
57
+ finally {
58
+ zeroize(kek);
59
+ }
60
+ }
61
+ /**
62
+ * Ephemeral-static X25519. A fresh ephemeral key per wrap means the shared secret,
63
+ * and therefore the key-encryption key, is unique to this archive and recipient.
64
+ */
65
+ export function wrapForKey(archiveKey, recipientPublicKey, suite, archiveId, options) {
66
+ const ephemeral = options.ephemeralPrivateKey ? { privateKey: options.ephemeralPrivateKey, publicKey: createPublicKey(options.ephemeralPrivateKey) } : generateKeyPairSync("x25519");
67
+ const recipientRaw = rawPublicKey(recipientPublicKey);
68
+ const ephemeralRaw = rawPublicKey(ephemeral.publicKey);
69
+ const shared = diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientPublicKey });
70
+ const kek = Buffer.from(hkdfSync("sha256", shared, Buffer.concat([recipientRaw, ephemeralRaw]), Buffer.from("vaultline-recipient:v2"), KEY_BYTES));
71
+ const id = keyRecipientId(recipientRaw);
72
+ try {
73
+ return { type: "x25519", id, recipientPublicKey: b64(recipientRaw), ephemeralPublicKey: b64(ephemeralRaw), nonce: b64(options.nonce), ciphertext: b64(sealKey(suite, kek, options.nonce, archiveKey, wrapAad(archiveId, id))) };
74
+ }
75
+ finally {
76
+ zeroize(kek, shared);
77
+ }
78
+ }
79
+ export function x25519PrivateKeyFromRaw(raw) {
80
+ if (raw.length !== 32)
81
+ throw new CryptoError("key_malformed", "An X25519 private key must be 32 bytes");
82
+ const der = Buffer.concat([Buffer.from("302e020100300506032b656e04220420", "hex"), raw]);
83
+ return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
84
+ }
85
+ export function x25519PublicKeyFromRaw(raw) {
86
+ if (raw.length !== 32)
87
+ throw new CryptoError("key_malformed", "An X25519 public key must be 32 bytes");
88
+ const der = Buffer.concat([Buffer.from("302a300506032b656e032100", "hex"), raw]);
89
+ return createPublicKey({ key: der, format: "der", type: "spki" });
90
+ }
91
+ /** Tries every recipient this unlock method could open, and reports a single failure if none do. */
92
+ export function unwrapArchiveKey(wrappedKeys, suite, archiveId, unlock) {
93
+ const failures = [];
94
+ for (const wrapped of wrappedKeys) {
95
+ if ("phrase" in unlock && wrapped.type === "phrase") {
96
+ const salt = unb64(wrapped.kdf.salt);
97
+ if (!timingSafeEqual(Buffer.from(phraseRecipientId(salt)), Buffer.from(wrapped.id))) {
98
+ failures.push("phrase recipient id mismatch");
99
+ continue;
100
+ }
101
+ const kek = derivePhraseKey(unlock.phrase, salt, wrapped.kdf.params);
102
+ try {
103
+ return openKey(suite, kek, unb64(wrapped.nonce), unb64(wrapped.ciphertext), wrapAad(archiveId, wrapped.id));
104
+ }
105
+ catch (error) {
106
+ failures.push(error.code);
107
+ }
108
+ finally {
109
+ zeroize(kek);
110
+ }
111
+ }
112
+ if ("privateKey" in unlock && wrapped.type === "x25519") {
113
+ const privateKey = typeof unlock.privateKey === "string" ? createPrivateKey(unlock.privateKey) : unlock.privateKey;
114
+ const shared = diffieHellman({ privateKey, publicKey: x25519PublicKeyFromRaw(unb64(wrapped.ephemeralPublicKey)) });
115
+ const kek = Buffer.from(hkdfSync("sha256", shared, Buffer.concat([unb64(wrapped.recipientPublicKey), unb64(wrapped.ephemeralPublicKey)]), Buffer.from("vaultline-recipient:v2"), KEY_BYTES));
116
+ try {
117
+ return openKey(suite, kek, unb64(wrapped.nonce), unb64(wrapped.ciphertext), wrapAad(archiveId, wrapped.id));
118
+ }
119
+ catch (error) {
120
+ failures.push(error.code);
121
+ }
122
+ finally {
123
+ zeroize(kek, shared);
124
+ }
125
+ }
126
+ }
127
+ throw new CryptoError("no_recipient", `No recipient could be opened with the supplied secret (${failures.length} attempted)`);
128
+ }
129
+ export const wrapInternals = { sealKey, openKey, NONCE_BYTES };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * SHA-256 with exportable mid-stream state.
3
+ *
4
+ * Node's `createHash` keeps its running state inside OpenSSL and offers no way
5
+ * to serialize it, which is exactly what a resumable streamed seal needs: an
6
+ * upload interrupted at chunk 300 must resume its whole-object plaintext hash
7
+ * and whole-object ciphertext hash without re-reading 1.2 GB of source. This
8
+ * file is FIPS 180-4 SHA-256 in plain TypeScript so that the entire state —
9
+ * eight hash words, a byte count, and the sub-block tail — can be written into
10
+ * a journal and picked up by a later process.
11
+ *
12
+ * Digesting runs on a copy of the state rather than consuming it, because one
13
+ * instance has to answer twice for the same bytes: the archive record speaks
14
+ * hex (`ciphertextSha256`) while the remote verification speaks base64
15
+ * (`remote.checksum`), and a hasher that died on its first digest would force
16
+ * the seal to run two of them over every chunk.
17
+ */
18
+ export declare class StreamingSha256 {
19
+ private readonly h;
20
+ private readonly w;
21
+ private readonly tail;
22
+ private tailLength;
23
+ private bytes;
24
+ update(data: Buffer): this;
25
+ /**
26
+ * Finalizes on a copy of the state: the running stream stays live, so hex and
27
+ * base64 can both be asked of the same instance and updates may continue —
28
+ * unlike `createHash`, which a digest consumes.
29
+ */
30
+ private finalDigest;
31
+ digestHex(): string;
32
+ digestBase64(): string;
33
+ /** JSON-safe string capturing H[0..7], byte count, and the unprocessed tail. */
34
+ exportState(): string;
35
+ /**
36
+ * Rebuilds a stream from `exportState` output. Shape is validated field by
37
+ * field and the checksum recomputed, so garbage throws here rather than
38
+ * continuing into a digest that looks plausible and is wrong.
39
+ */
40
+ static importState(state: string): StreamingSha256;
41
+ }