@agimon-ai/doompi-web-security 0.0.1-alpha.10

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 (81) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +92 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +1 -0
  4. package/dist/adapters/browserBundleVerifier.cjs +2 -0
  5. package/dist/adapters/browserBundleVerifier.cjs.map +1 -0
  6. package/dist/adapters/browserBundleVerifier.d.cts +45 -0
  7. package/dist/adapters/browserBundleVerifier.d.cts.map +1 -0
  8. package/dist/adapters/browserBundleVerifier.d.mts +45 -0
  9. package/dist/adapters/browserBundleVerifier.d.mts.map +1 -0
  10. package/dist/adapters/browserBundleVerifier.mjs +2 -0
  11. package/dist/adapters/browserBundleVerifier.mjs.map +1 -0
  12. package/dist/adapters/browserSealedChannel.cjs +2 -0
  13. package/dist/adapters/browserSealedChannel.cjs.map +1 -0
  14. package/dist/adapters/browserSealedChannel.d.cts +36 -0
  15. package/dist/adapters/browserSealedChannel.d.cts.map +1 -0
  16. package/dist/adapters/browserSealedChannel.d.mts +36 -0
  17. package/dist/adapters/browserSealedChannel.d.mts.map +1 -0
  18. package/dist/adapters/browserSealedChannel.mjs +2 -0
  19. package/dist/adapters/browserSealedChannel.mjs.map +1 -0
  20. package/dist/adapters/bundleSigner.cjs +2 -0
  21. package/dist/adapters/bundleSigner.cjs.map +1 -0
  22. package/dist/adapters/bundleSigner.d.cts +18 -0
  23. package/dist/adapters/bundleSigner.d.cts.map +1 -0
  24. package/dist/adapters/bundleSigner.d.mts +18 -0
  25. package/dist/adapters/bundleSigner.d.mts.map +1 -0
  26. package/dist/adapters/bundleSigner.mjs +2 -0
  27. package/dist/adapters/bundleSigner.mjs.map +1 -0
  28. package/dist/adapters/nodeSealedChannel.cjs +2 -0
  29. package/dist/adapters/nodeSealedChannel.cjs.map +1 -0
  30. package/dist/adapters/nodeSealedChannel.d.cts +40 -0
  31. package/dist/adapters/nodeSealedChannel.d.cts.map +1 -0
  32. package/dist/adapters/nodeSealedChannel.d.mts +40 -0
  33. package/dist/adapters/nodeSealedChannel.d.mts.map +1 -0
  34. package/dist/adapters/nodeSealedChannel.mjs +2 -0
  35. package/dist/adapters/nodeSealedChannel.mjs.map +1 -0
  36. package/dist/adapters/sealedTransport.cjs +2 -0
  37. package/dist/adapters/sealedTransport.cjs.map +1 -0
  38. package/dist/adapters/sealedTransport.d.cts +43 -0
  39. package/dist/adapters/sealedTransport.d.cts.map +1 -0
  40. package/dist/adapters/sealedTransport.d.mts +43 -0
  41. package/dist/adapters/sealedTransport.d.mts.map +1 -0
  42. package/dist/adapters/sealedTransport.mjs +2 -0
  43. package/dist/adapters/sealedTransport.mjs.map +1 -0
  44. package/dist/browser.cjs +1 -0
  45. package/dist/browser.d.cts +5 -0
  46. package/dist/browser.d.mts +5 -0
  47. package/dist/browser.mjs +1 -0
  48. package/dist/index.cjs +1 -0
  49. package/dist/index.d.cts +4 -0
  50. package/dist/index.d.mts +4 -0
  51. package/dist/index.mjs +1 -0
  52. package/dist/node.cjs +1 -0
  53. package/dist/node.d.cts +3 -0
  54. package/dist/node.d.mts +3 -0
  55. package/dist/node.mjs +1 -0
  56. package/dist/services/serialQueue.cjs +2 -0
  57. package/dist/services/serialQueue.cjs.map +1 -0
  58. package/dist/services/serialQueue.d.cts +23 -0
  59. package/dist/services/serialQueue.d.cts.map +1 -0
  60. package/dist/services/serialQueue.d.mts +23 -0
  61. package/dist/services/serialQueue.d.mts.map +1 -0
  62. package/dist/services/serialQueue.mjs +2 -0
  63. package/dist/services/serialQueue.mjs.map +1 -0
  64. package/dist/types/bundleManifest.cjs +3 -0
  65. package/dist/types/bundleManifest.cjs.map +1 -0
  66. package/dist/types/bundleManifest.d.cts +45 -0
  67. package/dist/types/bundleManifest.d.cts.map +1 -0
  68. package/dist/types/bundleManifest.d.mts +45 -0
  69. package/dist/types/bundleManifest.d.mts.map +1 -0
  70. package/dist/types/bundleManifest.mjs +3 -0
  71. package/dist/types/bundleManifest.mjs.map +1 -0
  72. package/dist/types/sealedChannel.cjs +2 -0
  73. package/dist/types/sealedChannel.cjs.map +1 -0
  74. package/dist/types/sealedChannel.d.cts +77 -0
  75. package/dist/types/sealedChannel.d.cts.map +1 -0
  76. package/dist/types/sealedChannel.d.mts +77 -0
  77. package/dist/types/sealedChannel.d.mts.map +1 -0
  78. package/dist/types/sealedChannel.mjs +2 -0
  79. package/dist/types/sealedChannel.mjs.map +1 -0
  80. package/llms.txt +9 -0
  81. package/package.json +76 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundleManifest.mjs","names":[],"sources":["../../src/types/bundleManifest.ts"],"sourcesContent":["/**\n * A signed description of cockpit assets for an independently trusted verifier.\n *\n * A manifest does not let a browser SPA defend itself against the server or TLS\n * edge that supplied its executable code. Consumers need a separately trusted\n * bootstrap and key before these signatures become a delivery guarantee.\n */\n\nexport const BUNDLE_MANIFEST_ROUTE = '/bundle-manifest.json';\nexport const BUNDLE_MANIFEST_VERSION = 2;\n\nconst SHA256 = /^[0-9a-f]{64}$/u;\nconst BASE64URL = /^[A-Za-z0-9_-]+$/u;\nconst CONTENT_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u;\n\nexport interface BundleAsset {\n /** Normalized absolute URL path, without a query or fragment. */\n path: string;\n /** Lowercase hex SHA-256 of the served bytes. */\n sha256: string;\n byteLength: number;\n /** MIME type of the response, without parameters. */\n contentType: string;\n}\n\nexport interface BundleManifest {\n version: typeof BUNDLE_MANIFEST_VERSION;\n /** Monotonically increasing persistent signer revision. */\n revision: number;\n /** Unix time in milliseconds. */\n builtAt: number;\n assets: BundleAsset[];\n}\n\nexport interface SignedBundleManifest {\n manifest: BundleManifest;\n /** Base64url IEEE P1363 ECDSA P-256 signature over the canonical serialization. */\n signature: string;\n /** Base64url SPKI of the signing key. Trust must be established out of band. */\n publicKey: string;\n}\n\nfunction hasExactKeys(value: object, expected: readonly string[]): boolean {\n const keys = Object.keys(value).sort();\n return keys.length === expected.length && keys.every((key, index) => key === expected[index]);\n}\n\nfunction isSafeInteger(value: unknown, minimum: number): value is number {\n return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum;\n}\n\nfunction isSafeAssetPath(value: unknown): value is string {\n if (typeof value !== 'string' || value.length < 2 || !value.startsWith('/')) return false;\n if (value.includes('\\\\') || value.includes('\\0') || value.includes('?') || value.includes('#')) return false;\n if (/[^\\x21-\\x7e]/u.test(value) || value.includes('//') || /%(?![0-9a-f]{2})/iu.test(value)) return false;\n if (/%(?:2e|2f|5c)/iu.test(value)) return false;\n return value\n .split('/')\n .slice(1)\n .every((segment) => segment !== '' && segment !== '.' && segment !== '..');\n}\n\n/** The exact bytes signed by Node and verified by WebCrypto. */\nexport function canonicalManifest(manifest: BundleManifest): string {\n const assets = [...manifest.assets].sort((left, right) =>\n left.path < right.path ? -1 : left.path > right.path ? 1 : 0,\n );\n const lines = assets.map(\n (asset) => `${asset.path}\\0${asset.sha256}\\0${String(asset.byteLength)}\\0${asset.contentType}`,\n );\n return [String(manifest.version), String(manifest.revision), String(manifest.builtAt), ...lines].join('\\n');\n}\n\nexport function isBundleManifest(value: unknown): value is BundleManifest {\n if (typeof value !== 'object' || value === null) return false;\n if (!hasExactKeys(value, ['assets', 'builtAt', 'revision', 'version'])) return false;\n const candidate = value as Partial<BundleManifest>;\n if (candidate.version !== BUNDLE_MANIFEST_VERSION) return false;\n if (!isSafeInteger(candidate.revision, 1) || !isSafeInteger(candidate.builtAt, 0)) return false;\n if (!Array.isArray(candidate.assets) || candidate.assets.length === 0) return false;\n\n const paths = new Set<string>();\n for (const valueAsset of candidate.assets) {\n if (typeof valueAsset !== 'object' || valueAsset === null) return false;\n if (!hasExactKeys(valueAsset, ['byteLength', 'contentType', 'path', 'sha256'])) return false;\n const asset = valueAsset as Partial<BundleAsset>;\n if (!isSafeAssetPath(asset.path) || paths.has(asset.path)) return false;\n if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) return false;\n if (!isSafeInteger(asset.byteLength, 0)) return false;\n if (typeof asset.contentType !== 'string' || !CONTENT_TYPE.test(asset.contentType)) return false;\n paths.add(asset.path);\n }\n return paths.has('/index.html');\n}\n\nexport function isSignedBundleManifest(value: unknown): value is SignedBundleManifest {\n if (typeof value !== 'object' || value === null) return false;\n if (!hasExactKeys(value, ['manifest', 'publicKey', 'signature'])) return false;\n const candidate = value as Partial<SignedBundleManifest>;\n return (\n isBundleManifest(candidate.manifest) &&\n typeof candidate.signature === 'string' &&\n candidate.signature.length === 86 &&\n BASE64URL.test(candidate.signature) &&\n typeof candidate.publicKey === 'string' &&\n candidate.publicKey.length <= 512 &&\n BASE64URL.test(candidate.publicKey)\n );\n}\n\n/** The recorded asset for one path, or undefined if the manifest does not cover it. */\nexport function assetFor(manifest: BundleManifest, path: string): BundleAsset | undefined {\n return manifest.assets.find((asset) => asset.path === path);\n}\n\n/** The recorded digest for one asset, or undefined if the manifest does not cover it. */\nexport function digestFor(manifest: BundleManifest, path: string): string | undefined {\n return assetFor(manifest, path)?.sha256;\n}\n"],"mappings":"AAQA,MAAa,EAAwB,wBACxB,EAA0B,EAEjC,EAAS,kBACT,EAAY,oBACZ,EAAe,4DA6BrB,SAAS,EAAa,EAAe,EAAsC,CACzE,IAAM,EAAO,OAAO,KAAK,CAAK,CAAC,CAAC,KAAK,EACrC,OAAO,EAAK,SAAW,EAAS,QAAU,EAAK,OAAO,EAAK,IAAU,IAAQ,EAAS,EAAM,CAC9F,CAEA,SAAS,EAAc,EAAgB,EAAkC,CACvE,OAAO,OAAO,GAAU,UAAY,OAAO,cAAc,CAAK,GAAK,GAAS,CAC9E,CAEA,SAAS,EAAgB,EAAiC,CAKxD,OAJI,OAAO,GAAU,UAAY,EAAM,OAAS,GAAK,CAAC,EAAM,WAAW,GAAG,GACtE,EAAM,SAAS,IAAI,GAAK,EAAM,SAAS,IAAI,GAAK,EAAM,SAAS,GAAG,GAAK,EAAM,SAAS,GAAG,GACzF,gBAAgB,KAAK,CAAK,GAAK,EAAM,SAAS,IAAI,GAAK,qBAAqB,KAAK,CAAK,GACtF,kBAAkB,KAAK,CAAK,EAAU,GACnC,EACJ,MAAM,GAAG,CAAC,CACV,MAAM,CAAC,CAAC,CACR,MAAO,GAAY,IAAY,IAAM,IAAY,KAAO,IAAY,IAAI,CAC7E,CAGA,SAAgB,EAAkB,EAAkC,CAIlE,IAAM,EAHS,CAAC,GAAG,EAAS,MAAM,CAAC,CAAC,MAAM,EAAM,IAC9C,EAAK,KAAO,EAAM,KAAO,GAAK,IAAK,KAAO,EAAM,KAE/B,CAAC,CAAC,IAClB,GAAU,GAAG,EAAM,KAAK,IAAI,EAAM,OAAO,IAAI,OAAO,EAAM,UAAU,EAAE,IAAI,EAAM,aACnF,EACA,MAAO,CAAC,OAAO,EAAS,OAAO,EAAG,OAAO,EAAS,QAAQ,EAAG,OAAO,EAAS,OAAO,EAAG,GAAG,CAAK,CAAC,CAAC,KAAK;CAAI,CAC5G,CAEA,SAAgB,EAAiB,EAAyC,CAExE,GADI,OAAO,GAAU,WAAY,GAC7B,CAAC,EAAa,EAAO,CAAC,SAAU,UAAW,WAAY,SAAS,CAAC,EAAG,MAAO,GAC/E,IAAM,EAAY,EAGlB,GAFI,EAAU,UAAA,GACV,CAAC,EAAc,EAAU,SAAU,CAAC,GAAK,CAAC,EAAc,EAAU,QAAS,CAAC,GAC5E,CAAC,MAAM,QAAQ,EAAU,MAAM,GAAK,EAAU,OAAO,SAAW,EAAG,MAAO,GAE9E,IAAM,EAAQ,IAAI,IAClB,IAAK,IAAM,KAAc,EAAU,OAAQ,CAEzC,GADI,OAAO,GAAe,WAAY,GAClC,CAAC,EAAa,EAAY,CAAC,aAAc,cAAe,OAAQ,QAAQ,CAAC,EAAG,MAAO,GACvF,IAAM,EAAQ,EAId,GAHI,CAAC,EAAgB,EAAM,IAAI,GAAK,EAAM,IAAI,EAAM,IAAI,GACpD,OAAO,EAAM,QAAW,UAAY,CAAC,EAAO,KAAK,EAAM,MAAM,GAC7D,CAAC,EAAc,EAAM,WAAY,CAAC,GAClC,OAAO,EAAM,aAAgB,UAAY,CAAC,EAAa,KAAK,EAAM,WAAW,EAAG,MAAO,GAC3F,EAAM,IAAI,EAAM,IAAI,CACtB,CACA,OAAO,EAAM,IAAI,aAAa,CAChC,CAEA,SAAgB,EAAuB,EAA+C,CAEpF,GADI,OAAO,GAAU,WAAY,GAC7B,CAAC,EAAa,EAAO,CAAC,WAAY,YAAa,WAAW,CAAC,EAAG,MAAO,GACzE,IAAM,EAAY,EAClB,OACE,EAAiB,EAAU,QAAQ,GACnC,OAAO,EAAU,WAAc,UAC/B,EAAU,UAAU,SAAW,IAC/B,EAAU,KAAK,EAAU,SAAS,GAClC,OAAO,EAAU,WAAc,UAC/B,EAAU,UAAU,QAAU,KAC9B,EAAU,KAAK,EAAU,SAAS,CAEtC,CAGA,SAAgB,EAAS,EAA0B,EAAuC,CACxF,OAAO,EAAS,OAAO,KAAM,GAAU,EAAM,OAAS,CAAI,CAC5D,CAGA,SAAgB,EAAU,EAA0B,EAAkC,CACpF,OAAO,EAAS,EAAU,CAAI,CAAC,EAAE,MACnC"}
@@ -0,0 +1,2 @@
1
+ const e=2**32,t=`doompi/sealed/c2s/v1`,n=`doompi/sealed/s2c/v1`;function r(e){return e===`c2s`?t:n}function i(e){if(typeof e!=`object`||!e)return!1;let t=e;return t.v===1&&typeof t.n==`string`&&typeof t.c==`string`}function a(e){switch(e){case`version`:return`The other end is speaking a different envelope version.`;case`malformed`:return`A sealed message was not shaped like one.`;case`replay`:return`A sealed message arrived out of order or twice.`;case`auth`:return`A sealed message failed its authentication tag, so it was altered in transit.`;case`exhausted`:return`This channel reached its message limit and must be re-established.`}}exports.CLIENT_TO_SERVER_INFO=t,exports.MAX_MESSAGES_PER_KEY=e,exports.NONCE_BYTES=12,exports.NONCE_COUNTER_BYTES=8,exports.NONCE_PREFIX_BYTES=4,exports.SEALED_BODY_HEADER=`x-doompi-sealed`,exports.SEALED_CLIENT_KEY_HEADER=`x-doompi-channel-key`,exports.SEALED_KEY_PARAM=`k`,exports.SEALED_VERSION=1,exports.SERVER_TO_CLIENT_INFO=n,exports.describeSealedFailure=a,exports.infoFor=r,exports.isSealedEnvelope=i;
2
+ //# sourceMappingURL=sealedChannel.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sealedChannel.cjs","names":[],"sources":["../../src/types/sealedChannel.ts"],"sourcesContent":["/**\n * The envelope that keeps the tunnel provider out of the payload.\n *\n * Cloudflare terminates TLS at its edge, so everything the cockpit carries\n * (prompts, file previews, model output) is plaintext to them. Sealing the\n * payload underneath their TLS leaves them a relay that can see timing and\n * sizes but not content.\n *\n * The key exchange is anchored on the QR rather than negotiated in band: the\n * host's ephemeral public key is printed on a screen the user is holding, so\n * there is no moment at which a relay could substitute its own. That is the\n * same job Telegram's emoji comparison does for a secret chat, except it\n * happens automatically instead of depending on two people comparing pictures.\n *\n * Declared here because the server seals with `node:crypto` and the browser\n * unseals with WebCrypto, and an envelope is only an envelope if both sides\n * read it identically.\n */\n\n/** Bumped when the envelope layout changes; a mismatch is refused, never guessed at. */\nexport const SEALED_VERSION = 1;\n\n/** Fragment key in the pairing URL carrying the host's ephemeral public key. */\nexport const SEALED_KEY_PARAM = 'k';\n\n/** AES-GCM standard nonce length. Longer is non-standard, shorter is unsafe. */\nexport const NONCE_BYTES = 12;\n/** Random per-channel prefix, so two channels never share a nonce space. */\nexport const NONCE_PREFIX_BYTES = 4;\n/** Monotonic per-direction counter filling the rest of the nonce. */\nexport const NONCE_COUNTER_BYTES = NONCE_BYTES - NONCE_PREFIX_BYTES;\n\n/**\n * Rekey well before the counter could wrap.\n *\n * A repeated nonce under one AES-GCM key is catastrophic, not merely weak: it\n * leaks the XOR of two plaintexts and the authentication subkey. The counter is\n * 64 bits, so this bound is nowhere near it; the point is that the bound exists\n * and is enforced rather than assumed.\n */\nexport const MAX_MESSAGES_PER_KEY = 2 ** 32;\n\n/**\n * HKDF info strings, one per direction.\n *\n * Separate keys per direction, not one shared key, so a message the server sent\n * can never be replayed back at it as though the client had sent it.\n */\nexport const CLIENT_TO_SERVER_INFO = 'doompi/sealed/c2s/v1';\nexport const SERVER_TO_CLIENT_INFO = 'doompi/sealed/s2c/v1';\n\nexport type SealedDirection = 'c2s' | 's2c';\n\nexport function infoFor(direction: SealedDirection): string {\n return direction === 'c2s' ? CLIENT_TO_SERVER_INFO : SERVER_TO_CLIENT_INFO;\n}\n\n/**\n * One sealed message, as it travels.\n *\n * Base64url rather than binary so the same envelope works on a JSON body and a\n * text WebSocket frame without a second encoding path to get wrong.\n */\nexport interface SealedEnvelope {\n v: number;\n /** Base64url, 4-byte prefix plus 8-byte counter. */\n n: string;\n /** Base64url ciphertext with the GCM tag appended. */\n c: string;\n}\n\nexport function isSealedEnvelope(value: unknown): value is SealedEnvelope {\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as Partial<SealedEnvelope>;\n return candidate.v === SEALED_VERSION && typeof candidate.n === 'string' && typeof candidate.c === 'string';\n}\n\n/** Header naming the client's ephemeral public key during the handshake. */\nexport const SEALED_CLIENT_KEY_HEADER = 'x-doompi-channel-key';\n/** Header carrying a sealed request body's envelope on a non-JSON request. */\nexport const SEALED_BODY_HEADER = 'x-doompi-sealed';\n\nexport type SealedFailure = 'version' | 'malformed' | 'replay' | 'auth' | 'exhausted';\n\n/**\n * Why an unseal failed, in words a diagnostic can show.\n *\n * A decryption failure means a blank cockpit, so every one of these has to name\n * its cause rather than leaving a dead page and no explanation.\n */\nexport function describeSealedFailure(failure: SealedFailure): string {\n switch (failure) {\n case 'version':\n return 'The other end is speaking a different envelope version.';\n case 'malformed':\n return 'A sealed message was not shaped like one.';\n case 'replay':\n return 'A sealed message arrived out of order or twice.';\n case 'auth':\n return 'A sealed message failed its authentication tag, so it was altered in transit.';\n case 'exhausted':\n return 'This channel reached its message limit and must be re-established.';\n }\n}\n"],"mappings":"AAoBA,MAoBa,EAAuB,GAAK,GAQ5B,EAAwB,uBACxB,EAAwB,uBAIrC,SAAgB,EAAQ,EAAoC,CAC1D,OAAO,IAAc,MAAQ,EAAwB,CACvD,CAgBA,SAAgB,EAAiB,EAAyC,CACxE,GAAI,OAAO,GAAU,WAAY,EAAgB,MAAO,GACxD,IAAM,EAAY,EAClB,OAAO,EAAU,IAAA,GAAwB,OAAO,EAAU,GAAM,UAAY,OAAO,EAAU,GAAM,QACrG,CAeA,SAAgB,EAAsB,EAAgC,CACpE,OAAQ,EAAR,CACE,IAAK,UACH,MAAO,0DACT,IAAK,YACH,MAAO,4CACT,IAAK,SACH,MAAO,kDACT,IAAK,OACH,MAAO,gFACT,IAAK,YACH,MAAO,oEACX,CACF"}
@@ -0,0 +1,77 @@
1
+ //#region src/types/sealedChannel.d.ts
2
+ /**
3
+ * The envelope that keeps the tunnel provider out of the payload.
4
+ *
5
+ * Cloudflare terminates TLS at its edge, so everything the cockpit carries
6
+ * (prompts, file previews, model output) is plaintext to them. Sealing the
7
+ * payload underneath their TLS leaves them a relay that can see timing and
8
+ * sizes but not content.
9
+ *
10
+ * The key exchange is anchored on the QR rather than negotiated in band: the
11
+ * host's ephemeral public key is printed on a screen the user is holding, so
12
+ * there is no moment at which a relay could substitute its own. That is the
13
+ * same job Telegram's emoji comparison does for a secret chat, except it
14
+ * happens automatically instead of depending on two people comparing pictures.
15
+ *
16
+ * Declared here because the server seals with `node:crypto` and the browser
17
+ * unseals with WebCrypto, and an envelope is only an envelope if both sides
18
+ * read it identically.
19
+ */
20
+ /** Bumped when the envelope layout changes; a mismatch is refused, never guessed at. */
21
+ declare const SEALED_VERSION = 1;
22
+ /** Fragment key in the pairing URL carrying the host's ephemeral public key. */
23
+ declare const SEALED_KEY_PARAM = "k";
24
+ /** AES-GCM standard nonce length. Longer is non-standard, shorter is unsafe. */
25
+ declare const NONCE_BYTES = 12;
26
+ /** Random per-channel prefix, so two channels never share a nonce space. */
27
+ declare const NONCE_PREFIX_BYTES = 4;
28
+ /** Monotonic per-direction counter filling the rest of the nonce. */
29
+ declare const NONCE_COUNTER_BYTES: number;
30
+ /**
31
+ * Rekey well before the counter could wrap.
32
+ *
33
+ * A repeated nonce under one AES-GCM key is catastrophic, not merely weak: it
34
+ * leaks the XOR of two plaintexts and the authentication subkey. The counter is
35
+ * 64 bits, so this bound is nowhere near it; the point is that the bound exists
36
+ * and is enforced rather than assumed.
37
+ */
38
+ declare const MAX_MESSAGES_PER_KEY: number;
39
+ /**
40
+ * HKDF info strings, one per direction.
41
+ *
42
+ * Separate keys per direction, not one shared key, so a message the server sent
43
+ * can never be replayed back at it as though the client had sent it.
44
+ */
45
+ declare const CLIENT_TO_SERVER_INFO = "doompi/sealed/c2s/v1";
46
+ declare const SERVER_TO_CLIENT_INFO = "doompi/sealed/s2c/v1";
47
+ type SealedDirection = 'c2s' | 's2c';
48
+ declare function infoFor(direction: SealedDirection): string;
49
+ /**
50
+ * One sealed message, as it travels.
51
+ *
52
+ * Base64url rather than binary so the same envelope works on a JSON body and a
53
+ * text WebSocket frame without a second encoding path to get wrong.
54
+ */
55
+ interface SealedEnvelope {
56
+ v: number;
57
+ /** Base64url, 4-byte prefix plus 8-byte counter. */
58
+ n: string;
59
+ /** Base64url ciphertext with the GCM tag appended. */
60
+ c: string;
61
+ }
62
+ declare function isSealedEnvelope(value: unknown): value is SealedEnvelope;
63
+ /** Header naming the client's ephemeral public key during the handshake. */
64
+ declare const SEALED_CLIENT_KEY_HEADER = "x-doompi-channel-key";
65
+ /** Header carrying a sealed request body's envelope on a non-JSON request. */
66
+ declare const SEALED_BODY_HEADER = "x-doompi-sealed";
67
+ type SealedFailure = 'version' | 'malformed' | 'replay' | 'auth' | 'exhausted';
68
+ /**
69
+ * Why an unseal failed, in words a diagnostic can show.
70
+ *
71
+ * A decryption failure means a blank cockpit, so every one of these has to name
72
+ * its cause rather than leaving a dead page and no explanation.
73
+ */
74
+ declare function describeSealedFailure(failure: SealedFailure): string;
75
+ //#endregion
76
+ export { CLIENT_TO_SERVER_INFO, MAX_MESSAGES_PER_KEY, NONCE_BYTES, NONCE_COUNTER_BYTES, NONCE_PREFIX_BYTES, SEALED_BODY_HEADER, SEALED_CLIENT_KEY_HEADER, SEALED_KEY_PARAM, SEALED_VERSION, SERVER_TO_CLIENT_INFO, SealedDirection, SealedEnvelope, SealedFailure, describeSealedFailure, infoFor, isSealedEnvelope };
77
+ //# sourceMappingURL=sealedChannel.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sealedChannel.d.cts","names":[],"sources":["../../src/types/sealedChannel.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;cAoBa;;cAGA;;cAGA;;cAEA;;cAEA;;;;;;;;;cAUA;;;;;;;cAQA;cACA;KAED;iBAEI,QAAQ,WAAW;;;;;;;UAUlB;EACf;;EAEA;;EAEA;;iBAGc,iBAAiB,iBAAiB,SAAS;;cAO9C;;cAEA;KAED;;;;;;;iBAQI,sBAAsB,SAAS"}
@@ -0,0 +1,77 @@
1
+ //#region src/types/sealedChannel.d.ts
2
+ /**
3
+ * The envelope that keeps the tunnel provider out of the payload.
4
+ *
5
+ * Cloudflare terminates TLS at its edge, so everything the cockpit carries
6
+ * (prompts, file previews, model output) is plaintext to them. Sealing the
7
+ * payload underneath their TLS leaves them a relay that can see timing and
8
+ * sizes but not content.
9
+ *
10
+ * The key exchange is anchored on the QR rather than negotiated in band: the
11
+ * host's ephemeral public key is printed on a screen the user is holding, so
12
+ * there is no moment at which a relay could substitute its own. That is the
13
+ * same job Telegram's emoji comparison does for a secret chat, except it
14
+ * happens automatically instead of depending on two people comparing pictures.
15
+ *
16
+ * Declared here because the server seals with `node:crypto` and the browser
17
+ * unseals with WebCrypto, and an envelope is only an envelope if both sides
18
+ * read it identically.
19
+ */
20
+ /** Bumped when the envelope layout changes; a mismatch is refused, never guessed at. */
21
+ declare const SEALED_VERSION = 1;
22
+ /** Fragment key in the pairing URL carrying the host's ephemeral public key. */
23
+ declare const SEALED_KEY_PARAM = "k";
24
+ /** AES-GCM standard nonce length. Longer is non-standard, shorter is unsafe. */
25
+ declare const NONCE_BYTES = 12;
26
+ /** Random per-channel prefix, so two channels never share a nonce space. */
27
+ declare const NONCE_PREFIX_BYTES = 4;
28
+ /** Monotonic per-direction counter filling the rest of the nonce. */
29
+ declare const NONCE_COUNTER_BYTES: number;
30
+ /**
31
+ * Rekey well before the counter could wrap.
32
+ *
33
+ * A repeated nonce under one AES-GCM key is catastrophic, not merely weak: it
34
+ * leaks the XOR of two plaintexts and the authentication subkey. The counter is
35
+ * 64 bits, so this bound is nowhere near it; the point is that the bound exists
36
+ * and is enforced rather than assumed.
37
+ */
38
+ declare const MAX_MESSAGES_PER_KEY: number;
39
+ /**
40
+ * HKDF info strings, one per direction.
41
+ *
42
+ * Separate keys per direction, not one shared key, so a message the server sent
43
+ * can never be replayed back at it as though the client had sent it.
44
+ */
45
+ declare const CLIENT_TO_SERVER_INFO = "doompi/sealed/c2s/v1";
46
+ declare const SERVER_TO_CLIENT_INFO = "doompi/sealed/s2c/v1";
47
+ type SealedDirection = 'c2s' | 's2c';
48
+ declare function infoFor(direction: SealedDirection): string;
49
+ /**
50
+ * One sealed message, as it travels.
51
+ *
52
+ * Base64url rather than binary so the same envelope works on a JSON body and a
53
+ * text WebSocket frame without a second encoding path to get wrong.
54
+ */
55
+ interface SealedEnvelope {
56
+ v: number;
57
+ /** Base64url, 4-byte prefix plus 8-byte counter. */
58
+ n: string;
59
+ /** Base64url ciphertext with the GCM tag appended. */
60
+ c: string;
61
+ }
62
+ declare function isSealedEnvelope(value: unknown): value is SealedEnvelope;
63
+ /** Header naming the client's ephemeral public key during the handshake. */
64
+ declare const SEALED_CLIENT_KEY_HEADER = "x-doompi-channel-key";
65
+ /** Header carrying a sealed request body's envelope on a non-JSON request. */
66
+ declare const SEALED_BODY_HEADER = "x-doompi-sealed";
67
+ type SealedFailure = 'version' | 'malformed' | 'replay' | 'auth' | 'exhausted';
68
+ /**
69
+ * Why an unseal failed, in words a diagnostic can show.
70
+ *
71
+ * A decryption failure means a blank cockpit, so every one of these has to name
72
+ * its cause rather than leaving a dead page and no explanation.
73
+ */
74
+ declare function describeSealedFailure(failure: SealedFailure): string;
75
+ //#endregion
76
+ export { CLIENT_TO_SERVER_INFO, MAX_MESSAGES_PER_KEY, NONCE_BYTES, NONCE_COUNTER_BYTES, NONCE_PREFIX_BYTES, SEALED_BODY_HEADER, SEALED_CLIENT_KEY_HEADER, SEALED_KEY_PARAM, SEALED_VERSION, SERVER_TO_CLIENT_INFO, SealedDirection, SealedEnvelope, SealedFailure, describeSealedFailure, infoFor, isSealedEnvelope };
77
+ //# sourceMappingURL=sealedChannel.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sealedChannel.d.mts","names":[],"sources":["../../src/types/sealedChannel.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;cAoBa;;cAGA;;cAGA;;cAEA;;cAEA;;;;;;;;;cAUA;;;;;;;cAQA;cACA;KAED;iBAEI,QAAQ,WAAW;;;;;;;UAUlB;EACf;;EAEA;;EAEA;;iBAGc,iBAAiB,iBAAiB,SAAS;;cAO9C;;cAEA;KAED;;;;;;;iBAQI,sBAAsB,SAAS"}
@@ -0,0 +1,2 @@
1
+ const e=1,t=`k`,n=12,r=4,i=8,a=2**32,o=`doompi/sealed/c2s/v1`,s=`doompi/sealed/s2c/v1`;function c(e){return e===`c2s`?o:s}function l(e){if(typeof e!=`object`||!e)return!1;let t=e;return t.v===1&&typeof t.n==`string`&&typeof t.c==`string`}const u=`x-doompi-channel-key`,d=`x-doompi-sealed`;function f(e){switch(e){case`version`:return`The other end is speaking a different envelope version.`;case`malformed`:return`A sealed message was not shaped like one.`;case`replay`:return`A sealed message arrived out of order or twice.`;case`auth`:return`A sealed message failed its authentication tag, so it was altered in transit.`;case`exhausted`:return`This channel reached its message limit and must be re-established.`}}export{o as CLIENT_TO_SERVER_INFO,a as MAX_MESSAGES_PER_KEY,n as NONCE_BYTES,i as NONCE_COUNTER_BYTES,r as NONCE_PREFIX_BYTES,d as SEALED_BODY_HEADER,u as SEALED_CLIENT_KEY_HEADER,t as SEALED_KEY_PARAM,e as SEALED_VERSION,s as SERVER_TO_CLIENT_INFO,f as describeSealedFailure,c as infoFor,l as isSealedEnvelope};
2
+ //# sourceMappingURL=sealedChannel.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sealedChannel.mjs","names":[],"sources":["../../src/types/sealedChannel.ts"],"sourcesContent":["/**\n * The envelope that keeps the tunnel provider out of the payload.\n *\n * Cloudflare terminates TLS at its edge, so everything the cockpit carries\n * (prompts, file previews, model output) is plaintext to them. Sealing the\n * payload underneath their TLS leaves them a relay that can see timing and\n * sizes but not content.\n *\n * The key exchange is anchored on the QR rather than negotiated in band: the\n * host's ephemeral public key is printed on a screen the user is holding, so\n * there is no moment at which a relay could substitute its own. That is the\n * same job Telegram's emoji comparison does for a secret chat, except it\n * happens automatically instead of depending on two people comparing pictures.\n *\n * Declared here because the server seals with `node:crypto` and the browser\n * unseals with WebCrypto, and an envelope is only an envelope if both sides\n * read it identically.\n */\n\n/** Bumped when the envelope layout changes; a mismatch is refused, never guessed at. */\nexport const SEALED_VERSION = 1;\n\n/** Fragment key in the pairing URL carrying the host's ephemeral public key. */\nexport const SEALED_KEY_PARAM = 'k';\n\n/** AES-GCM standard nonce length. Longer is non-standard, shorter is unsafe. */\nexport const NONCE_BYTES = 12;\n/** Random per-channel prefix, so two channels never share a nonce space. */\nexport const NONCE_PREFIX_BYTES = 4;\n/** Monotonic per-direction counter filling the rest of the nonce. */\nexport const NONCE_COUNTER_BYTES = NONCE_BYTES - NONCE_PREFIX_BYTES;\n\n/**\n * Rekey well before the counter could wrap.\n *\n * A repeated nonce under one AES-GCM key is catastrophic, not merely weak: it\n * leaks the XOR of two plaintexts and the authentication subkey. The counter is\n * 64 bits, so this bound is nowhere near it; the point is that the bound exists\n * and is enforced rather than assumed.\n */\nexport const MAX_MESSAGES_PER_KEY = 2 ** 32;\n\n/**\n * HKDF info strings, one per direction.\n *\n * Separate keys per direction, not one shared key, so a message the server sent\n * can never be replayed back at it as though the client had sent it.\n */\nexport const CLIENT_TO_SERVER_INFO = 'doompi/sealed/c2s/v1';\nexport const SERVER_TO_CLIENT_INFO = 'doompi/sealed/s2c/v1';\n\nexport type SealedDirection = 'c2s' | 's2c';\n\nexport function infoFor(direction: SealedDirection): string {\n return direction === 'c2s' ? CLIENT_TO_SERVER_INFO : SERVER_TO_CLIENT_INFO;\n}\n\n/**\n * One sealed message, as it travels.\n *\n * Base64url rather than binary so the same envelope works on a JSON body and a\n * text WebSocket frame without a second encoding path to get wrong.\n */\nexport interface SealedEnvelope {\n v: number;\n /** Base64url, 4-byte prefix plus 8-byte counter. */\n n: string;\n /** Base64url ciphertext with the GCM tag appended. */\n c: string;\n}\n\nexport function isSealedEnvelope(value: unknown): value is SealedEnvelope {\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as Partial<SealedEnvelope>;\n return candidate.v === SEALED_VERSION && typeof candidate.n === 'string' && typeof candidate.c === 'string';\n}\n\n/** Header naming the client's ephemeral public key during the handshake. */\nexport const SEALED_CLIENT_KEY_HEADER = 'x-doompi-channel-key';\n/** Header carrying a sealed request body's envelope on a non-JSON request. */\nexport const SEALED_BODY_HEADER = 'x-doompi-sealed';\n\nexport type SealedFailure = 'version' | 'malformed' | 'replay' | 'auth' | 'exhausted';\n\n/**\n * Why an unseal failed, in words a diagnostic can show.\n *\n * A decryption failure means a blank cockpit, so every one of these has to name\n * its cause rather than leaving a dead page and no explanation.\n */\nexport function describeSealedFailure(failure: SealedFailure): string {\n switch (failure) {\n case 'version':\n return 'The other end is speaking a different envelope version.';\n case 'malformed':\n return 'A sealed message was not shaped like one.';\n case 'replay':\n return 'A sealed message arrived out of order or twice.';\n case 'auth':\n return 'A sealed message failed its authentication tag, so it was altered in transit.';\n case 'exhausted':\n return 'This channel reached its message limit and must be re-established.';\n }\n}\n"],"mappings":"AAoBA,MAAa,EAAiB,EAGjB,EAAmB,IAGnB,EAAc,GAEd,EAAqB,EAErB,EAAsB,EAUtB,EAAuB,GAAK,GAQ5B,EAAwB,uBACxB,EAAwB,uBAIrC,SAAgB,EAAQ,EAAoC,CAC1D,OAAO,IAAc,MAAQ,EAAwB,CACvD,CAgBA,SAAgB,EAAiB,EAAyC,CACxE,GAAI,OAAO,GAAU,WAAY,EAAgB,MAAO,GACxD,IAAM,EAAY,EAClB,OAAO,EAAU,IAAA,GAAwB,OAAO,EAAU,GAAM,UAAY,OAAO,EAAU,GAAM,QACrG,CAGA,MAAa,EAA2B,uBAE3B,EAAqB,kBAUlC,SAAgB,EAAsB,EAAgC,CACpE,OAAQ,EAAR,CACE,IAAK,UACH,MAAO,0DACT,IAAK,YACH,MAAO,4CACT,IAAK,SACH,MAAO,kDACT,IAAK,OACH,MAAO,gFACT,IAAK,YACH,MAAO,oEACX,CACF"}
package/llms.txt ADDED
@@ -0,0 +1,9 @@
1
+ # @agimon-ai/doompi-web-security
2
+
3
+ Package-owned guidance for the DoomPi cockpit's shared security primitives.
4
+
5
+ ## Resources
6
+
7
+ - [Package README](./README.md): Why this is a package rather than a file in the cockpit, what each
8
+ subpath carries, and the three properties of the sealed channel that are silent when wrong
9
+ (per-direction keys, nonce construction, and serialized ordering).
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@agimon-ai/doompi-web-security",
3
+ "version": "0.0.1-alpha.10",
4
+ "description": "Shared security primitives for the DoomPi web cockpit: sealed channels and signed bundle manifests for independently trusted verifiers.",
5
+ "keywords": [
6
+ "ai",
7
+ "coding-agent",
8
+ "cryptography",
9
+ "developer-tools",
10
+ "doompi",
11
+ "pi-package",
12
+ "security",
13
+ "typescript",
14
+ "web"
15
+ ],
16
+ "homepage": "https://agimon.ai",
17
+ "bugs": {
18
+ "url": "https://github.com/AgiFlow/doompi/issues"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Vuong Ngo",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/AgiFlow/doompi.git",
25
+ "directory": "packages/core/doompi-web-security"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "llms.txt",
30
+ "README.md",
31
+ "LICENSE",
32
+ "package.json"
33
+ ],
34
+ "type": "module",
35
+ "main": "./dist/index.cjs",
36
+ "types": "./dist/index.d.mts",
37
+ "jsnext:main": "./dist/index.mjs",
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.mts",
41
+ "import": "./dist/index.mjs",
42
+ "require": "./dist/index.cjs"
43
+ },
44
+ "./browser": {
45
+ "types": "./dist/browser.d.mts",
46
+ "import": "./dist/browser.mjs",
47
+ "require": "./dist/browser.cjs"
48
+ },
49
+ "./node": {
50
+ "types": "./dist/node.d.mts",
51
+ "import": "./dist/node.mjs",
52
+ "require": "./dist/node.cjs"
53
+ },
54
+ "./package.json": "./package.json"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "26.4.0",
61
+ "@vitest/coverage-v8": "4.1.11",
62
+ "tsdown": "0.22.14",
63
+ "typescript": "7.0.2",
64
+ "vitest": "4.1.11"
65
+ },
66
+ "engines": {
67
+ "node": ">=22.19.0"
68
+ },
69
+ "scripts": {
70
+ "build": "tsdown",
71
+ "test": "vitest --run",
72
+ "typecheck": "tsc --noEmit",
73
+ "lint": "oxlint . && oxfmt . --check",
74
+ "fixcode": "oxlint . --fix && oxfmt ."
75
+ }
76
+ }