@agimon-ai/po-web-security 0.0.0-alpha.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 (68) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +100 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +1 -0
  4. package/dist/browser.cjs +1 -0
  5. package/dist/browser.d.mts +5 -0
  6. package/dist/browser.mjs +1 -0
  7. package/dist/index.cjs +1 -0
  8. package/dist/index.d.mts +4 -0
  9. package/dist/index.mjs +1 -0
  10. package/dist/node.cjs +1 -0
  11. package/dist/node.d.mts +4 -0
  12. package/dist/node.mjs +1 -0
  13. package/dist/services/browserBundleVerifier/index.cjs +2 -0
  14. package/dist/services/browserBundleVerifier/index.cjs.map +1 -0
  15. package/dist/services/browserBundleVerifier/index.d.mts +49 -0
  16. package/dist/services/browserBundleVerifier/index.d.mts.map +1 -0
  17. package/dist/services/browserBundleVerifier/index.mjs +2 -0
  18. package/dist/services/browserBundleVerifier/index.mjs.map +1 -0
  19. package/dist/services/browserSealedChannel/index.cjs +2 -0
  20. package/dist/services/browserSealedChannel/index.cjs.map +1 -0
  21. package/dist/services/browserSealedChannel/index.d.mts +35 -0
  22. package/dist/services/browserSealedChannel/index.d.mts.map +1 -0
  23. package/dist/services/browserSealedChannel/index.mjs +2 -0
  24. package/dist/services/browserSealedChannel/index.mjs.map +1 -0
  25. package/dist/services/bundleSigner/index.cjs +2 -0
  26. package/dist/services/bundleSigner/index.cjs.map +1 -0
  27. package/dist/services/bundleSigner/index.d.mts +25 -0
  28. package/dist/services/bundleSigner/index.d.mts.map +1 -0
  29. package/dist/services/bundleSigner/index.mjs +2 -0
  30. package/dist/services/bundleSigner/index.mjs.map +1 -0
  31. package/dist/services/deviceSignature/index.cjs +2 -0
  32. package/dist/services/deviceSignature/index.cjs.map +1 -0
  33. package/dist/services/deviceSignature/index.d.mts +9 -0
  34. package/dist/services/deviceSignature/index.d.mts.map +1 -0
  35. package/dist/services/deviceSignature/index.mjs +2 -0
  36. package/dist/services/deviceSignature/index.mjs.map +1 -0
  37. package/dist/services/nodeSealedChannel/index.cjs +2 -0
  38. package/dist/services/nodeSealedChannel/index.cjs.map +1 -0
  39. package/dist/services/nodeSealedChannel/index.d.mts +47 -0
  40. package/dist/services/nodeSealedChannel/index.d.mts.map +1 -0
  41. package/dist/services/nodeSealedChannel/index.mjs +2 -0
  42. package/dist/services/nodeSealedChannel/index.mjs.map +1 -0
  43. package/dist/services/sealedTransport/index.cjs +2 -0
  44. package/dist/services/sealedTransport/index.cjs.map +1 -0
  45. package/dist/services/sealedTransport/index.d.mts +42 -0
  46. package/dist/services/sealedTransport/index.d.mts.map +1 -0
  47. package/dist/services/sealedTransport/index.mjs +2 -0
  48. package/dist/services/sealedTransport/index.mjs.map +1 -0
  49. package/dist/services/serialQueue/index.cjs +2 -0
  50. package/dist/services/serialQueue/index.cjs.map +1 -0
  51. package/dist/services/serialQueue/index.d.mts +22 -0
  52. package/dist/services/serialQueue/index.d.mts.map +1 -0
  53. package/dist/services/serialQueue/index.mjs +2 -0
  54. package/dist/services/serialQueue/index.mjs.map +1 -0
  55. package/dist/types/bundleManifest.cjs +3 -0
  56. package/dist/types/bundleManifest.cjs.map +1 -0
  57. package/dist/types/bundleManifest.d.mts +62 -0
  58. package/dist/types/bundleManifest.d.mts.map +1 -0
  59. package/dist/types/bundleManifest.mjs +3 -0
  60. package/dist/types/bundleManifest.mjs.map +1 -0
  61. package/dist/types/sealedChannel.cjs +2 -0
  62. package/dist/types/sealedChannel.cjs.map +1 -0
  63. package/dist/types/sealedChannel.d.mts +76 -0
  64. package/dist/types/sealedChannel.d.mts.map +1 -0
  65. package/dist/types/sealedChannel.mjs +2 -0
  66. package/dist/types/sealedChannel.mjs.map +1 -0
  67. package/llms.txt +9 -0
  68. package/package.json +76 -0
@@ -0,0 +1,2 @@
1
+ import{attestationBytes as e}from"../../types/bundleManifest.mjs";import{createPublicKey as t,verify as n}from"node:crypto";const r=/^[A-Za-z0-9_-]+$/u;function i(e,t){return typeof e==`string`&&e.length>0&&e.length<=t&&r.test(e)}function a(e){try{let n=t({key:Buffer.from(e,`base64url`),format:`der`,type:`spki`});return n.asymmetricKeyType!==`ec`||n.asymmetricKeyDetails?.namedCurve!==`prime256v1`?void 0:n}catch{return}}function o(t,r,o,s){let c=e(r,o);if(!i(t,512)||!i(s,86)||s.length!==86)return!1;let l=a(t);if(l===void 0)return!1;try{return n(`sha256`,c,{key:l,dsaEncoding:`ieee-p1363`},Buffer.from(s,`base64url`))}catch{return!1}}export{o as verifyDeviceSignature};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["BASE64URL","isBase64Url","value","maximum","p256Key","key","createPublicKey","spki","verifyDeviceSignature","message","attestationBytes","tag","fields","signature","verify"],"sources":["../../../src/services/deviceSignature/index.ts"],"sourcesContent":["import { type KeyObject, createPublicKey, verify } from 'node:crypto';\n\nimport { attestationBytes } from '../../types/bundleManifest';\n\nconst CURVE = 'prime256v1';\nconst SIGN_ALGORITHM = 'sha256';\nconst P1363_SIGNATURE_LENGTH = 86;\nconst MAX_SPKI_LENGTH = 512;\nconst BASE64URL = /^[A-Za-z0-9_-]+$/u;\n\nfunction isBase64Url(value: unknown, maximum: number): value is string {\n return typeof value === 'string' && value.length > 0 && value.length <= maximum && BASE64URL.test(value);\n}\n\n/** The key for a base64url SPKI only when it is an EC P-256 public key. */\nfunction p256Key(spki: string): KeyObject | undefined {\n try {\n const key = createPublicKey({ key: Buffer.from(spki, 'base64url'), format: 'der', type: 'spki' });\n if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== CURVE) return undefined;\n return key;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Verifies a tagged device signature with `node:crypto`. The key must be an EC `prime256v1` SPKI and the\n * signature a base64url IEEE P1363 ECDSA SHA-256 signature over `attestationBytes(tag, fields)`.\n * False for any malformed key or signature and for a mismatch; throws only on a malformed tag.\n */\nexport function verifyDeviceSignature(\n spki: string,\n tag: string,\n fields: readonly string[],\n signature: string,\n): boolean {\n const message = attestationBytes(tag, fields);\n if (!isBase64Url(spki, MAX_SPKI_LENGTH)) return false;\n if (!isBase64Url(signature, P1363_SIGNATURE_LENGTH) || signature.length !== P1363_SIGNATURE_LENGTH) return false;\n const key = p256Key(spki);\n if (key === undefined) return false;\n try {\n return verify(SIGN_ALGORITHM, message, { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url'));\n } catch {\n return false;\n }\n}\n"],"mappings":"4HAIA,MAIMA,EAAY,oBAElB,SAASC,EAAY,EAAgB,EAAkC,CACrE,OAAO,OAAOC,GAAU,UAAYA,EAAM,OAAS,GAAKA,EAAM,QAAUC,GAAWH,EAAU,KAAKE,CAAK,CACzG,CAGA,SAASE,EAAQ,EAAqC,CACpD,GAAI,CACF,IAAMC,EAAMC,EAAgB,CAAE,IAAK,OAAO,KAAKC,EAAM,WAAW,EAAG,OAAQ,MAAO,KAAM,MAAO,CAAC,EAEhG,OADIF,EAAI,oBAAsB,MAAQA,EAAI,sBAAsB,aAAe,aAAO,OAC/EA,CACT,MAAQ,CACN,MACF,CACF,CAOA,SAAgBG,EACd,EACA,EACA,EACA,EACS,CACT,IAAMC,EAAUC,EAAiBC,EAAKC,CAAM,EAE5C,GADI,CAACX,EAAYM,EAAM,GAAe,GAClC,CAACN,EAAYY,EAAW,EAAsB,GAAKA,EAAU,SAAW,GAAwB,MAAO,GAC3G,IAAMR,EAAMD,EAAQG,CAAI,EACxB,GAAIF,IAAQ,IAAA,GAAW,MAAO,GAC9B,GAAI,CACF,OAAOS,EAAO,SAAgBL,EAAS,CAAE,MAAK,YAAa,YAAa,EAAG,OAAO,KAAKI,EAAW,WAAW,CAAC,CAChH,MAAQ,CACN,MAAO,EACT,CACF"}
@@ -0,0 +1,2 @@
1
+ const e=require("../../types/sealedChannel.cjs");let t=require("node:crypto");const n=`prime256v1`,r=`aes-256-gcm`,i=Buffer.from(`po/sealed/v1`);function a(n,r){return Buffer.from((0,t.hkdfSync)(`sha256`,n,i,Buffer.from(e.infoFor(r)),32))}function o(e,t){let n=Buffer.alloc(12);return e.copy(n,0,0,4),n.writeBigUInt64BE(t,4),n}function s(n,i,s){let c=a(n,i),l=a(n,i===`c2s`?`s2c`:`c2s`),u=0n,d=-1n;return{seal(n){if(u>=BigInt(e.MAX_MESSAGES_PER_KEY))return{ok:!1,failure:`exhausted`};let i=o(s,u),a=(0,t.createCipheriv)(r,c,i),l=Buffer.concat([a.update(n),a.final(),a.getAuthTag()]);return u+=1n,{ok:!0,envelope:{v:1,n:i.toString(`base64url`),c:l.toString(`base64url`)}}},open(n){let i=n;if(typeof i?.n!=`string`||typeof i.c!=`string`)return{ok:!1,failure:`malformed`};if(i.v!==1)return{ok:!1,failure:`version`};if(!e.isSealedEnvelope(n))return{ok:!1,failure:`malformed`};let a=Buffer.from(n.n,`base64url`),o=Buffer.from(n.c,`base64url`);if(a.length!==12||o.length<16)return{ok:!1,failure:`malformed`};let s=a.readBigUInt64BE(4);if(s<=d)return{ok:!1,failure:`replay`};let c=(0,t.createDecipheriv)(r,l,a);c.setAuthTag(o.subarray(o.length-16));let u;try{u=Buffer.concat([c.update(o.subarray(0,o.length-16)),c.final()])}catch{return{ok:!1,failure:`auth`}}return d=s,{ok:!0,plaintext:new Uint8Array(u)}}}}function c(e){if(e.length>128||!/^[A-Za-z0-9_-]+$/.test(e))return;let r;try{r=Buffer.from(e,`base64url`)}catch{return}if(!(r.length!==65&&r.length!==33||(r.length===65?r[0]!==4:r[0]!==2&&r[0]!==3)||r.toString(`base64url`)!==e))try{return t.ECDH.convertKey(r,n,void 0,void 0,`uncompressed`)}catch{return}}function l(){let e=(0,t.createECDH)(n);e.generateKeys();let r=/* @__PURE__ */ new Set;return{publicKey:e.getPublicKey().toString(`base64url`),accept(n){let i=c(n);if(i===void 0||r.has(i.toString(`base64url`)))return;let a;try{a=e.computeSecret(i)}catch{return}return r.add(i.toString(`base64url`)),s(a,`s2c`,(0,t.randomBytes)(4))}}}function u(){let e=(0,t.createECDH)(n);e.generateKeys();let r=!1;return{publicKey:e.getPublicKey().toString(`base64url`),accept(n){if(r)return;let i=c(n);if(i===void 0)return;let a;try{a=e.computeSecret(i)}catch{return}return r=!0,s(a,`c2s`,(0,t.randomBytes)(4))}}}exports.createClientHandshake=u,exports.createHostHandshake=l;
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["hkdfSync","infoFor","MAX_MESSAGES_PER_KEY","createCipheriv","isSealedEnvelope","createDecipheriv","ECDH","createECDH","randomBytes","require_sealedChannel","node_crypto","CURVE","CIPHER","HKDF_SALT","derive","secret","direction","nonceFor","nonce","prefix","counter","createChannel","sendKey","sendDirection","receiveKey","sendCounter","highestReceived","noncePrefix","cipher","body","plaintext","shape","envelope","decipher","ecdhPoint","value","encoded","createHostHandshake","exchange","acceptedPeers","peer","peerPublicKey","createClientHandshake","accepted","host","hostPublicKey"],"sources":["../../../src/services/nodeSealedChannel/index.ts"],"sourcesContent":["import { ECDH, createCipheriv, createDecipheriv, createECDH, hkdfSync, randomBytes } from 'node:crypto';\n\nimport {\n MAX_MESSAGES_PER_KEY,\n NONCE_BYTES,\n NONCE_PREFIX_BYTES,\n SEALED_VERSION,\n type SealedDirection,\n type SealedEnvelope,\n type SealedFailure,\n infoFor,\n isSealedEnvelope,\n} from '../../types/sealedChannel';\n\nconst CURVE = 'prime256v1';\nconst CIPHER = 'aes-256-gcm';\nconst KEY_BYTES = 32;\nconst TAG_BYTES = 16;\nconst HKDF_HASH = 'sha256';\n/** Fixed and public: HKDF's salt need not be secret, and a constant keeps both sides in step. */\nconst HKDF_SALT = Buffer.from('po/sealed/v1');\n\nexport type SealResult = { ok: true; envelope: SealedEnvelope } | { ok: false; failure: SealedFailure };\nexport type OpenResult = { ok: true; plaintext: Uint8Array } | { ok: false; failure: SealedFailure };\n\nexport interface SealedChannel {\n /** Seals one message. Counters advance only on success. */\n seal(plaintext: Uint8Array): SealResult;\n open(envelope: unknown): OpenResult;\n}\n\nexport interface HostHandshake {\n /** Base64url uncompressed P-256 point, printed in the QR. */\n publicKey: string;\n /** Completes the exchange against the peer's key and yields the channel. */\n accept(peerPublicKey: string): SealedChannel | undefined;\n}\n\nexport interface ClientHandshake {\n /** Base64url uncompressed P-256 point sent to the host. */\n publicKey: string;\n /** Completes the exchange against the host's fresh key and yields the channel. */\n accept(hostPublicKey: string): SealedChannel | undefined;\n}\n\nfunction derive(secret: Buffer, direction: SealedDirection): Buffer {\n return Buffer.from(hkdfSync(HKDF_HASH, secret, HKDF_SALT, Buffer.from(infoFor(direction)), KEY_BYTES));\n}\n\n/**\n * Builds the nonce for one message.\n *\n * A random per-channel prefix plus a monotonic counter, rather than a random\n * nonce per message: 96 bits is small enough that random nonces collide at a\n * rate worth caring about, and a repeated nonce under one AES-GCM key is\n * catastrophic rather than merely weak.\n */\nfunction nonceFor(prefix: Buffer, counter: bigint): Buffer {\n const nonce = Buffer.alloc(NONCE_BYTES);\n prefix.copy(nonce, 0, 0, NONCE_PREFIX_BYTES);\n nonce.writeBigUInt64BE(counter, NONCE_PREFIX_BYTES);\n return nonce;\n}\n\n/**\n * One direction's sealing state.\n *\n * Send and receive are separate keys and separate counters, so a message the\n * server sent can never be replayed back at it as though the client had sent it.\n */\nfunction createChannel(secret: Buffer, sendDirection: SealedDirection, noncePrefix: Buffer): SealedChannel {\n const sendKey = derive(secret, sendDirection);\n const receiveKey = derive(secret, sendDirection === 'c2s' ? 's2c' : 'c2s');\n let sendCounter = 0n;\n /** Strictly increasing: anything at or below what has been seen is a replay. */\n let highestReceived = -1n;\n\n return {\n seal(plaintext) {\n if (sendCounter >= BigInt(MAX_MESSAGES_PER_KEY)) return { ok: false, failure: 'exhausted' };\n const nonce = nonceFor(noncePrefix, sendCounter);\n const cipher = createCipheriv(CIPHER, sendKey, nonce);\n const body = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);\n sendCounter += 1n;\n return {\n ok: true,\n envelope: { v: SEALED_VERSION, n: nonce.toString('base64url'), c: body.toString('base64url') },\n };\n },\n\n open(envelope) {\n // Shape before version: something with no `v` at all is malformed, and\n // calling it a version mismatch would send a reader hunting for a\n // protocol skew that is not there.\n const shape = envelope as { v?: unknown; n?: unknown; c?: unknown } | null | undefined;\n if (typeof shape?.n !== 'string' || typeof shape.c !== 'string') return { ok: false, failure: 'malformed' };\n if (shape.v !== SEALED_VERSION) return { ok: false, failure: 'version' };\n if (!isSealedEnvelope(envelope)) return { ok: false, failure: 'malformed' };\n const nonce = Buffer.from(envelope.n, 'base64url');\n const body = Buffer.from(envelope.c, 'base64url');\n if (nonce.length !== NONCE_BYTES || body.length < TAG_BYTES) return { ok: false, failure: 'malformed' };\n const counter = nonce.readBigUInt64BE(NONCE_PREFIX_BYTES);\n if (counter <= highestReceived) return { ok: false, failure: 'replay' };\n const decipher = createDecipheriv(CIPHER, receiveKey, nonce);\n decipher.setAuthTag(body.subarray(body.length - TAG_BYTES));\n let plaintext: Buffer;\n try {\n plaintext = Buffer.concat([decipher.update(body.subarray(0, body.length - TAG_BYTES)), decipher.final()]);\n } catch {\n // A failed tag means the message was altered, which is the whole point\n // of having one. Never fall back to the ciphertext.\n return { ok: false, failure: 'auth' };\n }\n // Advanced only after the tag verified, so a forged high counter cannot\n // lock out the messages that follow it.\n highestReceived = counter;\n return { ok: true, plaintext: new Uint8Array(plaintext) };\n },\n };\n}\n\nfunction ecdhPoint(value: string): Buffer | undefined {\n if (value.length > 128 || !/^[A-Za-z0-9_-]+$/.test(value)) return undefined;\n let encoded: Buffer;\n try {\n encoded = Buffer.from(value, 'base64url');\n } catch {\n return undefined;\n }\n if (\n (encoded.length !== 65 && encoded.length !== 33) ||\n (encoded.length === 65 ? encoded[0] !== 4 : encoded[0] !== 2 && encoded[0] !== 3) ||\n encoded.toString('base64url') !== value\n )\n return undefined;\n try {\n return ECDH.convertKey(encoded, CURVE, undefined, undefined, 'uncompressed');\n } catch {\n return undefined;\n }\n}\n\n/**\n * The host half of the handshake.\n *\n * The ephemeral key pair is generated per tunnel and its public half goes in the\n * QR, which is the out-of-band channel that makes this authenticated. A relay\n * that swaps the client's key gets a channel with itself and nothing from the\n * host, because the host only ever derives against the key the client presents\n * and the client only ever derives against the key it read off the screen.\n */\nexport function createHostHandshake(): HostHandshake {\n const exchange = createECDH(CURVE);\n exchange.generateKeys();\n const acceptedPeers = new Set<string>();\n return {\n publicKey: exchange.getPublicKey().toString('base64url'),\n accept(peerPublicKey) {\n const peer = ecdhPoint(peerPublicKey);\n if (peer === undefined || acceptedPeers.has(peer.toString('base64url'))) return undefined;\n let secret: Buffer;\n try {\n secret = exchange.computeSecret(peer);\n } catch {\n return undefined;\n }\n acceptedPeers.add(peer.toString('base64url'));\n // The prefix is the host's to choose and travels in every nonce, so the\n // client reads it off the wire rather than needing it up front.\n return createChannel(secret, 's2c', randomBytes(NONCE_PREFIX_BYTES));\n },\n };\n}\n\n/** Creates the client half for a single fresh peer handshake. */\nexport function createClientHandshake(): ClientHandshake {\n const exchange = createECDH(CURVE);\n exchange.generateKeys();\n let accepted = false;\n return {\n publicKey: exchange.getPublicKey().toString('base64url'),\n accept(hostPublicKey) {\n if (accepted) return undefined;\n const host = ecdhPoint(hostPublicKey);\n if (host === undefined) return undefined;\n let secret: Buffer;\n try {\n secret = exchange.computeSecret(host);\n } catch {\n return undefined;\n }\n accepted = true;\n return createChannel(secret, 'c2s', randomBytes(NONCE_PREFIX_BYTES));\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ import { SealedEnvelope, SealedFailure } from "../../types/sealedChannel.mjs";
2
+ //#region src/services/nodeSealedChannel/index.d.ts
3
+ export type SealResult = {
4
+ ok: true;
5
+ envelope: SealedEnvelope;
6
+ } | {
7
+ ok: false;
8
+ failure: SealedFailure;
9
+ };
10
+ export type OpenResult = {
11
+ ok: true;
12
+ plaintext: Uint8Array;
13
+ } | {
14
+ ok: false;
15
+ failure: SealedFailure;
16
+ };
17
+ export interface SealedChannel {
18
+ /** Seals one message. Counters advance only on success. */
19
+ seal(plaintext: Uint8Array): SealResult;
20
+ open(envelope: unknown): OpenResult;
21
+ }
22
+ export interface HostHandshake {
23
+ /** Base64url uncompressed P-256 point, printed in the QR. */
24
+ publicKey: string;
25
+ /** Completes the exchange against the peer's key and yields the channel. */
26
+ accept(peerPublicKey: string): SealedChannel | undefined;
27
+ }
28
+ export interface ClientHandshake {
29
+ /** Base64url uncompressed P-256 point sent to the host. */
30
+ publicKey: string;
31
+ /** Completes the exchange against the host's fresh key and yields the channel. */
32
+ accept(hostPublicKey: string): SealedChannel | undefined;
33
+ }
34
+ /**
35
+ * The host half of the handshake.
36
+ *
37
+ * The ephemeral key pair is generated per tunnel and its public half goes in the
38
+ * QR, which is the out-of-band channel that makes this authenticated. A relay
39
+ * that swaps the client's key gets a channel with itself and nothing from the
40
+ * host, because the host only ever derives against the key the client presents
41
+ * and the client only ever derives against the key it read off the screen.
42
+ */
43
+ export declare function createHostHandshake(): HostHandshake;
44
+ /** Creates the client half for a single fresh peer handshake. */
45
+ export declare function createClientHandshake(): ClientHandshake;
46
+ //#endregion
47
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/services/nodeSealedChannel/index.ts"],"mappings":";;YAsBY;EAAe;EAAU,UAAU;;EAAqB;EAAW,SAAS;;YAC5E;EAAe;EAAU,WAAW;;EAAiB;EAAW,SAAS;;iBAEpE;;EAEf,KAAK,WAAW,aAAa;EAC7B,KAAK,oBAAoB;;iBAGV;;EAEf;;EAEA,OAAO,wBAAwB;;iBAGhB;;EAEf;;EAEA,OAAO,wBAAwB;;;;;;;;;;;wBA6GjB,uBAAuB;;wBAwBvB,yBAAyB"}
@@ -0,0 +1,2 @@
1
+ import{MAX_MESSAGES_PER_KEY as e,infoFor as t,isSealedEnvelope as n}from"../../types/sealedChannel.mjs";import{ECDH as r,createCipheriv as i,createDecipheriv as a,createECDH as o,hkdfSync as s,randomBytes as c}from"node:crypto";const l=`prime256v1`,u=`aes-256-gcm`,d=Buffer.from(`po/sealed/v1`);function f(e,n){return Buffer.from(s(`sha256`,e,d,Buffer.from(t(n)),32))}function p(e,t){let n=Buffer.alloc(12);return e.copy(n,0,0,4),n.writeBigUInt64BE(t,4),n}function m(t,r,o){let s=f(t,r),c=f(t,r===`c2s`?`s2c`:`c2s`),l=0n,d=-1n;return{seal(t){if(l>=BigInt(e))return{ok:!1,failure:`exhausted`};let n=p(o,l),r=i(u,s,n),a=Buffer.concat([r.update(t),r.final(),r.getAuthTag()]);return l+=1n,{ok:!0,envelope:{v:1,n:n.toString(`base64url`),c:a.toString(`base64url`)}}},open(e){let t=e;if(typeof t?.n!=`string`||typeof t.c!=`string`)return{ok:!1,failure:`malformed`};if(t.v!==1)return{ok:!1,failure:`version`};if(!n(e))return{ok:!1,failure:`malformed`};let r=Buffer.from(e.n,`base64url`),i=Buffer.from(e.c,`base64url`);if(r.length!==12||i.length<16)return{ok:!1,failure:`malformed`};let o=r.readBigUInt64BE(4);if(o<=d)return{ok:!1,failure:`replay`};let s=a(u,c,r);s.setAuthTag(i.subarray(i.length-16));let l;try{l=Buffer.concat([s.update(i.subarray(0,i.length-16)),s.final()])}catch{return{ok:!1,failure:`auth`}}return d=o,{ok:!0,plaintext:new Uint8Array(l)}}}}function h(e){if(e.length>128||!/^[A-Za-z0-9_-]+$/.test(e))return;let t;try{t=Buffer.from(e,`base64url`)}catch{return}if(!(t.length!==65&&t.length!==33||(t.length===65?t[0]!==4:t[0]!==2&&t[0]!==3)||t.toString(`base64url`)!==e))try{return r.convertKey(t,l,void 0,void 0,`uncompressed`)}catch{return}}function g(){let e=o(l);e.generateKeys();let t=/* @__PURE__ */ new Set;return{publicKey:e.getPublicKey().toString(`base64url`),accept(n){let r=h(n);if(r===void 0||t.has(r.toString(`base64url`)))return;let i;try{i=e.computeSecret(r)}catch{return}return t.add(r.toString(`base64url`)),m(i,`s2c`,c(4))}}}function _(){let e=o(l);e.generateKeys();let t=!1;return{publicKey:e.getPublicKey().toString(`base64url`),accept(n){if(t)return;let r=h(n);if(r===void 0)return;let i;try{i=e.computeSecret(r)}catch{return}return t=!0,m(i,`c2s`,c(4))}}}export{_ as createClientHandshake,g as createHostHandshake};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["CURVE","CIPHER","HKDF_SALT","derive","hkdfSync","secret","infoFor","direction","nonceFor","nonce","prefix","counter","createChannel","sendKey","sendDirection","receiveKey","sendCounter","highestReceived","MAX_MESSAGES_PER_KEY","noncePrefix","cipher","createCipheriv","body","plaintext","shape","envelope","isSealedEnvelope","decipher","createDecipheriv","ecdhPoint","value","encoded","ECDH","createHostHandshake","exchange","createECDH","acceptedPeers","peer","peerPublicKey","randomBytes","createClientHandshake","accepted","host","hostPublicKey"],"sources":["../../../src/services/nodeSealedChannel/index.ts"],"sourcesContent":["import { ECDH, createCipheriv, createDecipheriv, createECDH, hkdfSync, randomBytes } from 'node:crypto';\n\nimport {\n MAX_MESSAGES_PER_KEY,\n NONCE_BYTES,\n NONCE_PREFIX_BYTES,\n SEALED_VERSION,\n type SealedDirection,\n type SealedEnvelope,\n type SealedFailure,\n infoFor,\n isSealedEnvelope,\n} from '../../types/sealedChannel';\n\nconst CURVE = 'prime256v1';\nconst CIPHER = 'aes-256-gcm';\nconst KEY_BYTES = 32;\nconst TAG_BYTES = 16;\nconst HKDF_HASH = 'sha256';\n/** Fixed and public: HKDF's salt need not be secret, and a constant keeps both sides in step. */\nconst HKDF_SALT = Buffer.from('po/sealed/v1');\n\nexport type SealResult = { ok: true; envelope: SealedEnvelope } | { ok: false; failure: SealedFailure };\nexport type OpenResult = { ok: true; plaintext: Uint8Array } | { ok: false; failure: SealedFailure };\n\nexport interface SealedChannel {\n /** Seals one message. Counters advance only on success. */\n seal(plaintext: Uint8Array): SealResult;\n open(envelope: unknown): OpenResult;\n}\n\nexport interface HostHandshake {\n /** Base64url uncompressed P-256 point, printed in the QR. */\n publicKey: string;\n /** Completes the exchange against the peer's key and yields the channel. */\n accept(peerPublicKey: string): SealedChannel | undefined;\n}\n\nexport interface ClientHandshake {\n /** Base64url uncompressed P-256 point sent to the host. */\n publicKey: string;\n /** Completes the exchange against the host's fresh key and yields the channel. */\n accept(hostPublicKey: string): SealedChannel | undefined;\n}\n\nfunction derive(secret: Buffer, direction: SealedDirection): Buffer {\n return Buffer.from(hkdfSync(HKDF_HASH, secret, HKDF_SALT, Buffer.from(infoFor(direction)), KEY_BYTES));\n}\n\n/**\n * Builds the nonce for one message.\n *\n * A random per-channel prefix plus a monotonic counter, rather than a random\n * nonce per message: 96 bits is small enough that random nonces collide at a\n * rate worth caring about, and a repeated nonce under one AES-GCM key is\n * catastrophic rather than merely weak.\n */\nfunction nonceFor(prefix: Buffer, counter: bigint): Buffer {\n const nonce = Buffer.alloc(NONCE_BYTES);\n prefix.copy(nonce, 0, 0, NONCE_PREFIX_BYTES);\n nonce.writeBigUInt64BE(counter, NONCE_PREFIX_BYTES);\n return nonce;\n}\n\n/**\n * One direction's sealing state.\n *\n * Send and receive are separate keys and separate counters, so a message the\n * server sent can never be replayed back at it as though the client had sent it.\n */\nfunction createChannel(secret: Buffer, sendDirection: SealedDirection, noncePrefix: Buffer): SealedChannel {\n const sendKey = derive(secret, sendDirection);\n const receiveKey = derive(secret, sendDirection === 'c2s' ? 's2c' : 'c2s');\n let sendCounter = 0n;\n /** Strictly increasing: anything at or below what has been seen is a replay. */\n let highestReceived = -1n;\n\n return {\n seal(plaintext) {\n if (sendCounter >= BigInt(MAX_MESSAGES_PER_KEY)) return { ok: false, failure: 'exhausted' };\n const nonce = nonceFor(noncePrefix, sendCounter);\n const cipher = createCipheriv(CIPHER, sendKey, nonce);\n const body = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);\n sendCounter += 1n;\n return {\n ok: true,\n envelope: { v: SEALED_VERSION, n: nonce.toString('base64url'), c: body.toString('base64url') },\n };\n },\n\n open(envelope) {\n // Shape before version: something with no `v` at all is malformed, and\n // calling it a version mismatch would send a reader hunting for a\n // protocol skew that is not there.\n const shape = envelope as { v?: unknown; n?: unknown; c?: unknown } | null | undefined;\n if (typeof shape?.n !== 'string' || typeof shape.c !== 'string') return { ok: false, failure: 'malformed' };\n if (shape.v !== SEALED_VERSION) return { ok: false, failure: 'version' };\n if (!isSealedEnvelope(envelope)) return { ok: false, failure: 'malformed' };\n const nonce = Buffer.from(envelope.n, 'base64url');\n const body = Buffer.from(envelope.c, 'base64url');\n if (nonce.length !== NONCE_BYTES || body.length < TAG_BYTES) return { ok: false, failure: 'malformed' };\n const counter = nonce.readBigUInt64BE(NONCE_PREFIX_BYTES);\n if (counter <= highestReceived) return { ok: false, failure: 'replay' };\n const decipher = createDecipheriv(CIPHER, receiveKey, nonce);\n decipher.setAuthTag(body.subarray(body.length - TAG_BYTES));\n let plaintext: Buffer;\n try {\n plaintext = Buffer.concat([decipher.update(body.subarray(0, body.length - TAG_BYTES)), decipher.final()]);\n } catch {\n // A failed tag means the message was altered, which is the whole point\n // of having one. Never fall back to the ciphertext.\n return { ok: false, failure: 'auth' };\n }\n // Advanced only after the tag verified, so a forged high counter cannot\n // lock out the messages that follow it.\n highestReceived = counter;\n return { ok: true, plaintext: new Uint8Array(plaintext) };\n },\n };\n}\n\nfunction ecdhPoint(value: string): Buffer | undefined {\n if (value.length > 128 || !/^[A-Za-z0-9_-]+$/.test(value)) return undefined;\n let encoded: Buffer;\n try {\n encoded = Buffer.from(value, 'base64url');\n } catch {\n return undefined;\n }\n if (\n (encoded.length !== 65 && encoded.length !== 33) ||\n (encoded.length === 65 ? encoded[0] !== 4 : encoded[0] !== 2 && encoded[0] !== 3) ||\n encoded.toString('base64url') !== value\n )\n return undefined;\n try {\n return ECDH.convertKey(encoded, CURVE, undefined, undefined, 'uncompressed');\n } catch {\n return undefined;\n }\n}\n\n/**\n * The host half of the handshake.\n *\n * The ephemeral key pair is generated per tunnel and its public half goes in the\n * QR, which is the out-of-band channel that makes this authenticated. A relay\n * that swaps the client's key gets a channel with itself and nothing from the\n * host, because the host only ever derives against the key the client presents\n * and the client only ever derives against the key it read off the screen.\n */\nexport function createHostHandshake(): HostHandshake {\n const exchange = createECDH(CURVE);\n exchange.generateKeys();\n const acceptedPeers = new Set<string>();\n return {\n publicKey: exchange.getPublicKey().toString('base64url'),\n accept(peerPublicKey) {\n const peer = ecdhPoint(peerPublicKey);\n if (peer === undefined || acceptedPeers.has(peer.toString('base64url'))) return undefined;\n let secret: Buffer;\n try {\n secret = exchange.computeSecret(peer);\n } catch {\n return undefined;\n }\n acceptedPeers.add(peer.toString('base64url'));\n // The prefix is the host's to choose and travels in every nonce, so the\n // client reads it off the wire rather than needing it up front.\n return createChannel(secret, 's2c', randomBytes(NONCE_PREFIX_BYTES));\n },\n };\n}\n\n/** Creates the client half for a single fresh peer handshake. */\nexport function createClientHandshake(): ClientHandshake {\n const exchange = createECDH(CURVE);\n exchange.generateKeys();\n let accepted = false;\n return {\n publicKey: exchange.getPublicKey().toString('base64url'),\n accept(hostPublicKey) {\n if (accepted) return undefined;\n const host = ecdhPoint(hostPublicKey);\n if (host === undefined) return undefined;\n let secret: Buffer;\n try {\n secret = exchange.computeSecret(host);\n } catch {\n return undefined;\n }\n accepted = true;\n return createChannel(secret, 'c2s', randomBytes(NONCE_PREFIX_BYTES));\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ const e=require("../../types/sealedChannel.cjs"),t=require("../browserSealedChannel/index.cjs"),n=require("../serialQueue/index.cjs"),r=new TextEncoder,i=new TextDecoder;function a(e){let t=``;for(let n of e)t+=String.fromCharCode(n);return btoa(t)}function o(e){let t=atob(e);return Uint8Array.from(t,e=>e.charCodeAt(0))}async function s(e){if(e!=null){if(typeof e==`string`)return r.encode(e);if(e instanceof URLSearchParams)return r.encode(e.toString());if(e instanceof Blob)return new Uint8Array(await e.arrayBuffer());if(e instanceof ArrayBuffer)return new Uint8Array(e);if(ArrayBuffer.isView(e))return new Uint8Array(e.buffer,e.byteOffset,e.byteLength);throw Error(`The sealed HTTP transport does not support this request body type.`)}}function c(e){if(!e.startsWith(`/`))throw Error(`A sealed HTTP request must use a root-relative URL.`);return e}function l(e){if(typeof e!=`object`||!e)return!1;let t=e;return t.v===1&&typeof t.status==`number`&&Array.isArray(t.headers)&&t.headers.every(e=>Array.isArray(e)&&e.length===2&&e.every(e=>typeof e==`string`))&&typeof t.body==`string`}function u(){let u,d,f=n.createSerialQueue(),p=n.createSerialQueue(),m=n.createSerialQueue(),h,g=t=>{d=e.describeSealedFailure(t)},_=async e=>{if(u===void 0)return;let t=await u.seal(e);return t.ok?JSON.stringify(t.envelope):g(t.failure)},v=async e=>{if(u===void 0)return;let t;try{t=JSON.parse(e)}catch{return g(`malformed`)}let n=await u.open(t);return n.ok?n.plaintext:g(n.failure)};return{active:()=>u!==void 0,lastFailure:()=>d,async connect(e){let n=await t.connectSealedChannel(e);if(n===void 0){d=`This browser could not establish a sealed channel.`;return}return u=n.channel,d=void 0,n.clientPublicKey},relayRequestsThrough(e){h=e},reset(){u=void 0,d=void 0},async sealText(e){return u===void 0?e:await f.run(async()=>{let t=await _(r.encode(e));if(t===void 0)throw Error(d??`The active sealed channel could not encrypt a message.`);return t})},async openText(e){return u===void 0?e:await p.run(async()=>{let t=await v(e);return t===void 0?void 0:i.decode(t)})},async sealBinary(e){return u===void 0?e:await f.run(async()=>{let t=await _(e);if(t===void 0)throw Error(d??`The active sealed channel could not encrypt a message.`);return r.encode(t)})},async openBinary(e){return u===void 0?e:await p.run(async()=>await v(i.decode(e)))},async fetch(e,t){let n=h;return u===void 0||n===void 0?await fetch(e,t):await m.run(async()=>{let u=await s(t?.body),f={v:1,method:(t?.method??`GET`).toUpperCase(),target:c(e),headers:Array.from(new Headers(t?.headers).entries()),...u===void 0?{}:{body:a(u)}},p=await _(r.encode(JSON.stringify(f)));if(p===void 0)return new Response(JSON.stringify({error:d}),{status:502});let m=await fetch(n,{method:`POST`,credentials:`same-origin`,headers:{"content-type":`application/json`},body:p}),h=await v(await m.text());if(h===void 0)return new Response(JSON.stringify({error:d}),{status:502});let g;try{g=JSON.parse(i.decode(h))}catch{g=void 0}if(!l(g))return d=`The sealed HTTP response was malformed.`,new Response(JSON.stringify({error:d}),{status:502});let y=o(g.body);return new Response(g.status===204||g.status===205||g.status===304?null:y,{status:g.status,headers:g.headers})})}}}const d=u();exports.createSealedTransport=u,exports.sealedTransport=d;
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["createSerialQueue","describeSealedFailure","connectSealedChannel","require_sealedChannel","require_index","require_index$1","encoder","decoder","encodeBase64","binary","byte","bytes","decodeBase64","value","character","bodyBytes","body","requestTarget","input","isSealedHttpResponse","candidate","header","part","createSealedTransport","channel","failure","outbound","inbound","exchange","relayPath","note","reason","sealBytes","sealed","openBytes","envelope","text","opened","connected","hostPublicKey","path","gateway","init","request","relayResponse","decoded","sealedTransport"],"sources":["../../../src/services/sealedTransport/index.ts"],"sourcesContent":["import { describeSealedFailure, type SealedFailure } from '../../types/sealedChannel';\nimport { connectSealedChannel, type SealedChannel } from '../browserSealedChannel';\nimport { createSerialQueue } from '../serialQueue';\n\n/**\n * The one object the cockpit and every plugin use to talk over a sealed tunnel.\n *\n * Two things make this worth having rather than each caller reaching for the\n * channel directly. First, ordering: sealing advances a nonce counter and\n * opening enforces that it strictly increases, so both directions are pushed\n * through a serial queue and overlapping calls can never reorder into a\n * self-inflicted replay. Second, reach: a plugin that calls `fetch` itself\n * sends plaintext to whoever is relaying the tunnel, and the host cannot stop\n * it. Giving every caller one obvious helper is the only enforcement available.\n *\n * When no channel is established every method is a pass-through, so the same\n * code runs unchanged on the loopback listener where there is nothing to seal.\n */\n\nconst JSON_CONTENT_TYPE = 'application/json';\n\nexport interface SealedTransport {\n /** False on loopback and before the handshake completes, where everything passes through. */\n active(): boolean;\n sealText(text: string): Promise<string>;\n /** Undefined when the message could not be opened; `lastFailure` says why. */\n openText(text: string): Promise<string | undefined>;\n sealBinary(bytes: Uint8Array): Promise<Uint8Array>;\n openBinary(bytes: Uint8Array): Promise<Uint8Array | undefined>;\n /** Drop-in for `fetch`, relaying the complete request through the sealed channel when configured. */\n fetch(input: string, init?: RequestInit): Promise<Response>;\n lastFailure(): string | undefined;\n}\n\nconst encoder = new TextEncoder();\nconst decoder = new TextDecoder();\n\ninterface SealedHttpRequest {\n v: 1;\n method: string;\n target: string;\n headers: Array<[string, string]>;\n body?: string;\n}\n\ninterface SealedHttpResponse {\n v: 1;\n status: number;\n headers: Array<[string, string]>;\n body: string;\n}\n\nfunction encodeBase64(bytes: Uint8Array): string {\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nfunction decodeBase64(value: string): Uint8Array {\n const binary = atob(value);\n return Uint8Array.from(binary, (character) => character.charCodeAt(0));\n}\n\nasync function bodyBytes(body: RequestInit['body']): Promise<Uint8Array | undefined> {\n if (body === undefined || body === null) return undefined;\n if (typeof body === 'string') return encoder.encode(body);\n if (body instanceof URLSearchParams) return encoder.encode(body.toString());\n if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer());\n if (body instanceof ArrayBuffer) return new Uint8Array(body);\n if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);\n throw new Error('The sealed HTTP transport does not support this request body type.');\n}\n\nfunction requestTarget(input: string): string {\n if (!input.startsWith('/')) throw new Error('A sealed HTTP request must use a root-relative URL.');\n return input;\n}\n\nfunction isSealedHttpResponse(value: unknown): value is SealedHttpResponse {\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as Partial<SealedHttpResponse>;\n return (\n candidate.v === 1 &&\n typeof candidate.status === 'number' &&\n Array.isArray(candidate.headers) &&\n candidate.headers.every(\n (header) => Array.isArray(header) && header.length === 2 && header.every((part) => typeof part === 'string'),\n ) &&\n typeof candidate.body === 'string'\n );\n}\nexport function createSealedTransport(): SealedTransport & {\n /** Completes the handshake against the host key from the QR. */\n connect: (hostPublicKey: string) => Promise<string | undefined>;\n /** Drops the channel, so everything falls back to pass-through. */\n reset: () => void;\n /** Selects the same-origin endpoint that carries sealed HTTP exchanges. */\n relayRequestsThrough: (path: string) => void;\n} {\n let channel: SealedChannel | undefined;\n let failure: string | undefined;\n const outbound = createSerialQueue();\n const inbound = createSerialQueue();\n const exchange = createSerialQueue();\n let relayPath: string | undefined;\n const note = (reason: SealedFailure): undefined => {\n // A decryption failure otherwise shows up as a blank page with no cause, so\n // every one of them names itself.\n failure = describeSealedFailure(reason);\n return undefined;\n };\n\n const sealBytes = async (bytes: Uint8Array): Promise<string | undefined> => {\n if (channel === undefined) return undefined;\n const sealed = await channel.seal(bytes);\n if (!sealed.ok) return note(sealed.failure);\n return JSON.stringify(sealed.envelope);\n };\n\n const openBytes = async (text: string): Promise<Uint8Array | undefined> => {\n if (channel === undefined) return undefined;\n let envelope: unknown;\n try {\n envelope = JSON.parse(text);\n } catch {\n return note('malformed');\n }\n const opened = await channel.open(envelope);\n if (!opened.ok) return note(opened.failure);\n return opened.plaintext;\n };\n\n return {\n active: () => channel !== undefined,\n lastFailure: () => failure,\n\n async connect(hostPublicKey) {\n const connected = await connectSealedChannel(hostPublicKey);\n if (connected === undefined) {\n failure = 'This browser could not establish a sealed channel.';\n return undefined;\n }\n channel = connected.channel;\n failure = undefined;\n return connected.clientPublicKey;\n },\n\n relayRequestsThrough(path) {\n relayPath = path;\n },\n\n reset() {\n channel = undefined;\n failure = undefined;\n },\n\n async sealText(text) {\n if (channel === undefined) return text;\n return await outbound.run(async () => {\n const sealed = await sealBytes(encoder.encode(text));\n if (sealed === undefined) throw new Error(failure ?? 'The active sealed channel could not encrypt a message.');\n return sealed;\n });\n },\n\n async openText(text) {\n if (channel === undefined) return text;\n return await inbound.run(async () => {\n const opened = await openBytes(text);\n return opened === undefined ? undefined : decoder.decode(opened);\n });\n },\n\n async sealBinary(bytes) {\n if (channel === undefined) return bytes;\n return await outbound.run(async () => {\n const sealed = await sealBytes(bytes);\n if (sealed === undefined) throw new Error(failure ?? 'The active sealed channel could not encrypt a message.');\n return encoder.encode(sealed);\n });\n },\n\n async openBinary(bytes) {\n if (channel === undefined) return bytes;\n return await inbound.run(async () => await openBytes(decoder.decode(bytes)));\n },\n\n async fetch(input, init) {\n const gateway = relayPath;\n if (channel === undefined || gateway === undefined) return await fetch(input, init);\n return await exchange.run(async () => {\n const bytes = await bodyBytes(init?.body);\n const request: SealedHttpRequest = {\n v: 1,\n method: (init?.method ?? 'GET').toUpperCase(),\n target: requestTarget(input),\n headers: Array.from(new Headers(init?.headers).entries()),\n ...(bytes === undefined ? {} : { body: encodeBase64(bytes) }),\n };\n const sealed = await sealBytes(encoder.encode(JSON.stringify(request)));\n if (sealed === undefined) {\n return new Response(JSON.stringify({ error: failure }), { status: 502 });\n }\n const relayResponse = await fetch(gateway, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': JSON_CONTENT_TYPE },\n body: sealed,\n });\n const opened = await openBytes(await relayResponse.text());\n if (opened === undefined) {\n return new Response(JSON.stringify({ error: failure }), { status: 502 });\n }\n let decoded: unknown;\n try {\n decoded = JSON.parse(decoder.decode(opened));\n } catch {\n decoded = undefined;\n }\n if (!isSealedHttpResponse(decoded)) {\n failure = 'The sealed HTTP response was malformed.';\n return new Response(JSON.stringify({ error: failure }), { status: 502 });\n }\n const body = decodeBase64(decoded.body);\n return new Response(decoded.status === 204 || decoded.status === 205 || decoded.status === 304 ? null : body, {\n status: decoded.status,\n headers: decoded.headers,\n });\n });\n },\n };\n}\n\n/**\n * The page's one sealed channel.\n *\n * A module singleton rather than an instance per caller, because the cockpit\n * and every plugin are bundled into the same page and all of them must share\n * one pair of nonce counters. Two transports over one handshake would each\n * start counting at zero and the receiver would reject the second as a replay.\n *\n * Inactive until a handshake completes, and inactive forever on the loopback\n * listener, so `sealedTransport.fetch` is a plain `fetch` there.\n */\nexport const sealedTransport = createSealedTransport();\n"],"mappings":"sIAkCMM,EAAU,IAAI,YACdC,EAAU,IAAI,YAiBpB,SAASC,EAAa,EAA2B,CAC/C,IAAIC,EAAS,GACb,IAAK,IAAMC,KAAQC,EAAO,GAAU,OAAO,aAAaD,CAAI,EAC5D,OAAO,KAAKD,CAAM,CACpB,CAEA,SAASG,EAAa,EAA2B,CAC/C,IAAMH,EAAS,KAAKI,CAAK,EACzB,OAAO,WAAW,KAAKJ,EAAS,GAAcK,EAAU,WAAW,CAAC,CAAC,CACvE,CAEA,eAAeC,EAAU,EAA4D,CACnF,GAAIC,GAA+B,KACnC,IAAI,OAAOA,GAAS,SAAU,OAAOV,EAAQ,OAAOU,CAAI,EACxD,GAAIA,aAAgB,gBAAiB,OAAOV,EAAQ,OAAOU,EAAK,SAAS,CAAC,EAC1E,GAAIA,aAAgB,KAAM,OAAO,IAAI,WAAW,MAAMA,EAAK,YAAY,CAAC,EACxE,GAAIA,aAAgB,YAAa,OAAO,IAAI,WAAWA,CAAI,EAC3D,GAAI,YAAY,OAAOA,CAAI,EAAG,OAAO,IAAI,WAAWA,EAAK,OAAQA,EAAK,WAAYA,EAAK,UAAU,EACjG,MAAU,MAAM,oEAAoE,CAL5B,CAM1D,CAEA,SAASC,EAAc,EAAuB,CAC5C,GAAI,CAACC,EAAM,WAAW,GAAG,EAAG,MAAU,MAAM,qDAAqD,EACjG,OAAOA,CACT,CAEA,SAASC,EAAqB,EAA6C,CACzE,GAAI,OAAON,GAAU,WAAYA,EAAgB,MAAO,GACxD,IAAMO,EAAYP,EAClB,OACEO,EAAU,IAAM,GAChB,OAAOA,EAAU,QAAW,UAC5B,MAAM,QAAQA,EAAU,OAAO,GAC/BA,EAAU,QAAQ,MACf,GAAW,MAAM,QAAQC,CAAM,GAAKA,EAAO,SAAW,GAAKA,EAAO,MAAO,GAAS,OAAOC,GAAS,QAAQ,CAC7G,GACA,OAAOF,EAAU,MAAS,QAE9B,CACA,SAAgBG,GAOd,CACA,IAAIC,EACAC,EACEC,EAAW1B,EAAAA,kBAAkB,EAC7B2B,EAAU3B,EAAAA,kBAAkB,EAC5B4B,EAAW5B,EAAAA,kBAAkB,EAC/B6B,EACEC,EAAQ,GAAqC,CAGjD,EAAU7B,EAAAA,sBAAsB8B,CAAM,CAExC,EAEMC,EAAY,KAAO,IAAmD,CAC1E,GAAIR,IAAY,IAAA,GAAW,OAC3B,IAAMS,EAAS,MAAMT,EAAQ,KAAKb,CAAK,EAEvC,OADKsB,EAAO,GACL,KAAK,UAAUA,EAAO,QAAQ,EADdH,EAAKG,EAAO,OAAO,CAE5C,EAEMC,EAAY,KAAO,IAAkD,CACzE,GAAIV,IAAY,IAAA,GAAW,OAC3B,IAAIW,EACJ,GAAI,CACF,EAAW,KAAK,MAAMC,CAAI,CAC5B,MAAQ,CACN,OAAON,EAAK,WAAW,CACzB,CACA,IAAMO,EAAS,MAAMb,EAAQ,KAAKW,CAAQ,EAE1C,OADKE,EAAO,GACLA,EAAO,UADSP,EAAKO,EAAO,OAAO,CAE5C,EAEA,MAAO,CACL,WAAcb,IAAY,IAAA,GAC1B,gBAAmBC,EAEnB,MAAM,QAAQ,EAAe,CAC3B,IAAMa,EAAY,MAAMpC,EAAAA,qBAAqBqC,CAAa,EAC1D,GAAID,IAAc,IAAA,GAAW,CAC3B,EAAU,qDACV,MACF,CAGA,MAFA,GAAUA,EAAU,QACpB,EAAU,IAAA,GACHA,EAAU,eACnB,EAEA,qBAAqB,EAAM,CACzB,EAAYE,CACd,EAEA,OAAQ,CACN,EAAU,IAAA,GACV,EAAU,IAAA,EACZ,EAEA,MAAM,SAAS,EAAM,CAEnB,OADIhB,IAAY,IAAA,GAAkBY,EAC3B,MAAMV,EAAS,IAAI,SAAY,CACpC,IAAMO,EAAS,MAAMD,EAAU1B,EAAQ,OAAO8B,CAAI,CAAC,EACnD,GAAIH,IAAW,IAAA,GAAW,MAAU,MAAMR,GAAW,wDAAwD,EAC7G,OAAOQ,CACT,CAAC,CACH,EAEA,MAAM,SAAS,EAAM,CAEnB,OADIT,IAAY,IAAA,GAAkBY,EAC3B,MAAMT,EAAQ,IAAI,SAAY,CACnC,IAAMU,EAAS,MAAMH,EAAUE,CAAI,EACnC,OAAOC,IAAW,IAAA,GAAY,IAAA,GAAY9B,EAAQ,OAAO8B,CAAM,CACjE,CAAC,CACH,EAEA,MAAM,WAAW,EAAO,CAEtB,OADIb,IAAY,IAAA,GAAkBb,EAC3B,MAAMe,EAAS,IAAI,SAAY,CACpC,IAAMO,EAAS,MAAMD,EAAUrB,CAAK,EACpC,GAAIsB,IAAW,IAAA,GAAW,MAAU,MAAMR,GAAW,wDAAwD,EAC7G,OAAOnB,EAAQ,OAAO2B,CAAM,CAC9B,CAAC,CACH,EAEA,MAAM,WAAW,EAAO,CAEtB,OADIT,IAAY,IAAA,GAAkBb,EAC3B,MAAMgB,EAAQ,IAAI,SAAY,MAAMO,EAAU3B,EAAQ,OAAOI,CAAK,CAAC,CAAC,CAC7E,EAEA,MAAM,MAAM,EAAO,EAAM,CACvB,IAAM8B,EAAUZ,EAEhB,OADIL,IAAY,IAAA,IAAaiB,IAAY,IAAA,GAAkB,MAAM,MAAMvB,EAAOwB,CAAI,EAC3E,MAAMd,EAAS,IAAI,SAAY,CACpC,IAAMjB,EAAQ,MAAMI,EAAU2B,GAAM,IAAI,EAClCC,EAA6B,CACjC,EAAG,EACH,QAASD,GAAM,QAAU,MAAA,CAAO,YAAY,EAC5C,OAAQzB,EAAcC,CAAK,EAC3B,QAAS,MAAM,KAAK,IAAI,QAAQwB,GAAM,OAAO,CAAC,CAAC,QAAQ,CAAC,EACxD,GAAI/B,IAAU,IAAA,GAAY,CAAC,EAAI,CAAE,KAAMH,EAAaG,CAAK,CAAE,CAC7D,EACMsB,EAAS,MAAMD,EAAU1B,EAAQ,OAAO,KAAK,UAAUqC,CAAO,CAAC,CAAC,EACtE,GAAIV,IAAW,IAAA,GACb,OAAO,IAAI,SAAS,KAAK,UAAU,CAAE,MAAOR,CAAQ,CAAC,EAAG,CAAE,OAAQ,GAAI,CAAC,EAEzE,IAAMmB,EAAgB,MAAM,MAAMH,EAAS,CACzC,OAAQ,OACR,YAAa,cACb,QAAS,CAAE,eAAgB,kBAAkB,EAC7C,KAAMR,CACR,CAAC,EACKI,EAAS,MAAMH,EAAU,MAAMU,EAAc,KAAK,CAAC,EACzD,GAAIP,IAAW,IAAA,GACb,OAAO,IAAI,SAAS,KAAK,UAAU,CAAE,MAAOZ,CAAQ,CAAC,EAAG,CAAE,OAAQ,GAAI,CAAC,EAEzE,IAAIoB,EACJ,GAAI,CACF,EAAU,KAAK,MAAMtC,EAAQ,OAAO8B,CAAM,CAAC,CAC7C,MAAQ,CACN,EAAU,IAAA,EACZ,CACA,GAAI,CAAClB,EAAqB0B,CAAO,EAE/B,MADA,GAAU,0CACH,IAAI,SAAS,KAAK,UAAU,CAAE,MAAOpB,CAAQ,CAAC,EAAG,CAAE,OAAQ,GAAI,CAAC,EAEzE,IAAMT,EAAOJ,EAAaiC,EAAQ,IAAI,EACtC,OAAO,IAAI,SAASA,EAAQ,SAAW,KAAOA,EAAQ,SAAW,KAAOA,EAAQ,SAAW,IAAM,KAAO7B,EAAM,CAC5G,OAAQ6B,EAAQ,OAChB,QAASA,EAAQ,OACnB,CAAC,CACH,CAAC,CACH,CACF,CACF,CAaA,MAAaC,EAAkBvB,EAAsB"}
@@ -0,0 +1,42 @@
1
+ //#region src/services/sealedTransport/index.d.ts
2
+ export interface SealedTransport {
3
+ /** False on loopback and before the handshake completes, where everything passes through. */
4
+ active(): boolean;
5
+ sealText(text: string): Promise<string>;
6
+ /** Undefined when the message could not be opened; `lastFailure` says why. */
7
+ openText(text: string): Promise<string | undefined>;
8
+ sealBinary(bytes: Uint8Array): Promise<Uint8Array>;
9
+ openBinary(bytes: Uint8Array): Promise<Uint8Array | undefined>;
10
+ /** Drop-in for `fetch`, relaying the complete request through the sealed channel when configured. */
11
+ fetch(input: string, init?: RequestInit): Promise<Response>;
12
+ lastFailure(): string | undefined;
13
+ }
14
+ export declare function createSealedTransport(): SealedTransport & {
15
+ /** Completes the handshake against the host key from the QR. */
16
+ connect: (hostPublicKey: string) => Promise<string | undefined>;
17
+ /** Drops the channel, so everything falls back to pass-through. */
18
+ reset: () => void;
19
+ /** Selects the same-origin endpoint that carries sealed HTTP exchanges. */
20
+ relayRequestsThrough: (path: string) => void;
21
+ };
22
+ /**
23
+ * The page's one sealed channel.
24
+ *
25
+ * A module singleton rather than an instance per caller, because the cockpit
26
+ * and every plugin are bundled into the same page and all of them must share
27
+ * one pair of nonce counters. Two transports over one handshake would each
28
+ * start counting at zero and the receiver would reject the second as a replay.
29
+ *
30
+ * Inactive until a handshake completes, and inactive forever on the loopback
31
+ * listener, so `sealedTransport.fetch` is a plain `fetch` there.
32
+ */
33
+ export declare const sealedTransport: SealedTransport & {
34
+ /** Completes the handshake against the host key from the QR. */
35
+ connect: (hostPublicKey: string) => Promise<string | undefined>;
36
+ /** Drops the channel, so everything falls back to pass-through. */
37
+ reset: () => void;
38
+ /** Selects the same-origin endpoint that carries sealed HTTP exchanges. */
39
+ relayRequestsThrough: (path: string) => void;
40
+ };
41
+ //#endregion
42
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/services/sealedTransport/index.ts"],"mappings":";iBAqBiB;;EAEf;EACA,SAAS,eAAe;;EAExB,SAAS,eAAe;EACxB,WAAW,OAAO,aAAa,QAAQ;EACvC,WAAW,OAAO,aAAa,QAAQ;;EAEvC,MAAM,eAAe,OAAO,cAAc,QAAQ;EAClD;;wBA4Dc,yBAAyB;;EAEvC,UAAU,0BAA0B;;EAEpC;;EAEA,uBAAuB;;;;;;;;;;;;;qBAmJZ,iBAAe;;EAvJjB,UAAC,0BAA0B;;EAE7B;;EAEe,uBAAC"}
@@ -0,0 +1,2 @@
1
+ import{describeSealedFailure as e}from"../../types/sealedChannel.mjs";import{connectSealedChannel as t}from"../browserSealedChannel/index.mjs";import{createSerialQueue as n}from"../serialQueue/index.mjs";const r=new TextEncoder,i=new TextDecoder;function a(e){let t=``;for(let n of e)t+=String.fromCharCode(n);return btoa(t)}function o(e){let t=atob(e);return Uint8Array.from(t,e=>e.charCodeAt(0))}async function s(e){if(e!=null){if(typeof e==`string`)return r.encode(e);if(e instanceof URLSearchParams)return r.encode(e.toString());if(e instanceof Blob)return new Uint8Array(await e.arrayBuffer());if(e instanceof ArrayBuffer)return new Uint8Array(e);if(ArrayBuffer.isView(e))return new Uint8Array(e.buffer,e.byteOffset,e.byteLength);throw Error(`The sealed HTTP transport does not support this request body type.`)}}function c(e){if(!e.startsWith(`/`))throw Error(`A sealed HTTP request must use a root-relative URL.`);return e}function l(e){if(typeof e!=`object`||!e)return!1;let t=e;return t.v===1&&typeof t.status==`number`&&Array.isArray(t.headers)&&t.headers.every(e=>Array.isArray(e)&&e.length===2&&e.every(e=>typeof e==`string`))&&typeof t.body==`string`}function u(){let u,d,f=n(),p=n(),m=n(),h,g=t=>{d=e(t)},_=async e=>{if(u===void 0)return;let t=await u.seal(e);return t.ok?JSON.stringify(t.envelope):g(t.failure)},v=async e=>{if(u===void 0)return;let t;try{t=JSON.parse(e)}catch{return g(`malformed`)}let n=await u.open(t);return n.ok?n.plaintext:g(n.failure)};return{active:()=>u!==void 0,lastFailure:()=>d,async connect(e){let n=await t(e);if(n===void 0){d=`This browser could not establish a sealed channel.`;return}return u=n.channel,d=void 0,n.clientPublicKey},relayRequestsThrough(e){h=e},reset(){u=void 0,d=void 0},async sealText(e){return u===void 0?e:await f.run(async()=>{let t=await _(r.encode(e));if(t===void 0)throw Error(d??`The active sealed channel could not encrypt a message.`);return t})},async openText(e){return u===void 0?e:await p.run(async()=>{let t=await v(e);return t===void 0?void 0:i.decode(t)})},async sealBinary(e){return u===void 0?e:await f.run(async()=>{let t=await _(e);if(t===void 0)throw Error(d??`The active sealed channel could not encrypt a message.`);return r.encode(t)})},async openBinary(e){return u===void 0?e:await p.run(async()=>await v(i.decode(e)))},async fetch(e,t){let n=h;return u===void 0||n===void 0?await fetch(e,t):await m.run(async()=>{let u=await s(t?.body),f={v:1,method:(t?.method??`GET`).toUpperCase(),target:c(e),headers:Array.from(new Headers(t?.headers).entries()),...u===void 0?{}:{body:a(u)}},p=await _(r.encode(JSON.stringify(f)));if(p===void 0)return new Response(JSON.stringify({error:d}),{status:502});let m=await fetch(n,{method:`POST`,credentials:`same-origin`,headers:{"content-type":`application/json`},body:p}),h=await v(await m.text());if(h===void 0)return new Response(JSON.stringify({error:d}),{status:502});let g;try{g=JSON.parse(i.decode(h))}catch{g=void 0}if(!l(g))return d=`The sealed HTTP response was malformed.`,new Response(JSON.stringify({error:d}),{status:502});let y=o(g.body);return new Response(g.status===204||g.status===205||g.status===304?null:y,{status:g.status,headers:g.headers})})}}}const d=u();export{u as createSealedTransport,d as sealedTransport};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["encoder","decoder","encodeBase64","binary","byte","bytes","decodeBase64","value","character","bodyBytes","body","requestTarget","input","isSealedHttpResponse","candidate","header","part","createSealedTransport","channel","failure","outbound","createSerialQueue","inbound","exchange","relayPath","note","describeSealedFailure","reason","sealBytes","sealed","openBytes","envelope","text","opened","connected","connectSealedChannel","hostPublicKey","path","gateway","init","request","relayResponse","decoded","sealedTransport"],"sources":["../../../src/services/sealedTransport/index.ts"],"sourcesContent":["import { describeSealedFailure, type SealedFailure } from '../../types/sealedChannel';\nimport { connectSealedChannel, type SealedChannel } from '../browserSealedChannel';\nimport { createSerialQueue } from '../serialQueue';\n\n/**\n * The one object the cockpit and every plugin use to talk over a sealed tunnel.\n *\n * Two things make this worth having rather than each caller reaching for the\n * channel directly. First, ordering: sealing advances a nonce counter and\n * opening enforces that it strictly increases, so both directions are pushed\n * through a serial queue and overlapping calls can never reorder into a\n * self-inflicted replay. Second, reach: a plugin that calls `fetch` itself\n * sends plaintext to whoever is relaying the tunnel, and the host cannot stop\n * it. Giving every caller one obvious helper is the only enforcement available.\n *\n * When no channel is established every method is a pass-through, so the same\n * code runs unchanged on the loopback listener where there is nothing to seal.\n */\n\nconst JSON_CONTENT_TYPE = 'application/json';\n\nexport interface SealedTransport {\n /** False on loopback and before the handshake completes, where everything passes through. */\n active(): boolean;\n sealText(text: string): Promise<string>;\n /** Undefined when the message could not be opened; `lastFailure` says why. */\n openText(text: string): Promise<string | undefined>;\n sealBinary(bytes: Uint8Array): Promise<Uint8Array>;\n openBinary(bytes: Uint8Array): Promise<Uint8Array | undefined>;\n /** Drop-in for `fetch`, relaying the complete request through the sealed channel when configured. */\n fetch(input: string, init?: RequestInit): Promise<Response>;\n lastFailure(): string | undefined;\n}\n\nconst encoder = new TextEncoder();\nconst decoder = new TextDecoder();\n\ninterface SealedHttpRequest {\n v: 1;\n method: string;\n target: string;\n headers: Array<[string, string]>;\n body?: string;\n}\n\ninterface SealedHttpResponse {\n v: 1;\n status: number;\n headers: Array<[string, string]>;\n body: string;\n}\n\nfunction encodeBase64(bytes: Uint8Array): string {\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nfunction decodeBase64(value: string): Uint8Array {\n const binary = atob(value);\n return Uint8Array.from(binary, (character) => character.charCodeAt(0));\n}\n\nasync function bodyBytes(body: RequestInit['body']): Promise<Uint8Array | undefined> {\n if (body === undefined || body === null) return undefined;\n if (typeof body === 'string') return encoder.encode(body);\n if (body instanceof URLSearchParams) return encoder.encode(body.toString());\n if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer());\n if (body instanceof ArrayBuffer) return new Uint8Array(body);\n if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);\n throw new Error('The sealed HTTP transport does not support this request body type.');\n}\n\nfunction requestTarget(input: string): string {\n if (!input.startsWith('/')) throw new Error('A sealed HTTP request must use a root-relative URL.');\n return input;\n}\n\nfunction isSealedHttpResponse(value: unknown): value is SealedHttpResponse {\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as Partial<SealedHttpResponse>;\n return (\n candidate.v === 1 &&\n typeof candidate.status === 'number' &&\n Array.isArray(candidate.headers) &&\n candidate.headers.every(\n (header) => Array.isArray(header) && header.length === 2 && header.every((part) => typeof part === 'string'),\n ) &&\n typeof candidate.body === 'string'\n );\n}\nexport function createSealedTransport(): SealedTransport & {\n /** Completes the handshake against the host key from the QR. */\n connect: (hostPublicKey: string) => Promise<string | undefined>;\n /** Drops the channel, so everything falls back to pass-through. */\n reset: () => void;\n /** Selects the same-origin endpoint that carries sealed HTTP exchanges. */\n relayRequestsThrough: (path: string) => void;\n} {\n let channel: SealedChannel | undefined;\n let failure: string | undefined;\n const outbound = createSerialQueue();\n const inbound = createSerialQueue();\n const exchange = createSerialQueue();\n let relayPath: string | undefined;\n const note = (reason: SealedFailure): undefined => {\n // A decryption failure otherwise shows up as a blank page with no cause, so\n // every one of them names itself.\n failure = describeSealedFailure(reason);\n return undefined;\n };\n\n const sealBytes = async (bytes: Uint8Array): Promise<string | undefined> => {\n if (channel === undefined) return undefined;\n const sealed = await channel.seal(bytes);\n if (!sealed.ok) return note(sealed.failure);\n return JSON.stringify(sealed.envelope);\n };\n\n const openBytes = async (text: string): Promise<Uint8Array | undefined> => {\n if (channel === undefined) return undefined;\n let envelope: unknown;\n try {\n envelope = JSON.parse(text);\n } catch {\n return note('malformed');\n }\n const opened = await channel.open(envelope);\n if (!opened.ok) return note(opened.failure);\n return opened.plaintext;\n };\n\n return {\n active: () => channel !== undefined,\n lastFailure: () => failure,\n\n async connect(hostPublicKey) {\n const connected = await connectSealedChannel(hostPublicKey);\n if (connected === undefined) {\n failure = 'This browser could not establish a sealed channel.';\n return undefined;\n }\n channel = connected.channel;\n failure = undefined;\n return connected.clientPublicKey;\n },\n\n relayRequestsThrough(path) {\n relayPath = path;\n },\n\n reset() {\n channel = undefined;\n failure = undefined;\n },\n\n async sealText(text) {\n if (channel === undefined) return text;\n return await outbound.run(async () => {\n const sealed = await sealBytes(encoder.encode(text));\n if (sealed === undefined) throw new Error(failure ?? 'The active sealed channel could not encrypt a message.');\n return sealed;\n });\n },\n\n async openText(text) {\n if (channel === undefined) return text;\n return await inbound.run(async () => {\n const opened = await openBytes(text);\n return opened === undefined ? undefined : decoder.decode(opened);\n });\n },\n\n async sealBinary(bytes) {\n if (channel === undefined) return bytes;\n return await outbound.run(async () => {\n const sealed = await sealBytes(bytes);\n if (sealed === undefined) throw new Error(failure ?? 'The active sealed channel could not encrypt a message.');\n return encoder.encode(sealed);\n });\n },\n\n async openBinary(bytes) {\n if (channel === undefined) return bytes;\n return await inbound.run(async () => await openBytes(decoder.decode(bytes)));\n },\n\n async fetch(input, init) {\n const gateway = relayPath;\n if (channel === undefined || gateway === undefined) return await fetch(input, init);\n return await exchange.run(async () => {\n const bytes = await bodyBytes(init?.body);\n const request: SealedHttpRequest = {\n v: 1,\n method: (init?.method ?? 'GET').toUpperCase(),\n target: requestTarget(input),\n headers: Array.from(new Headers(init?.headers).entries()),\n ...(bytes === undefined ? {} : { body: encodeBase64(bytes) }),\n };\n const sealed = await sealBytes(encoder.encode(JSON.stringify(request)));\n if (sealed === undefined) {\n return new Response(JSON.stringify({ error: failure }), { status: 502 });\n }\n const relayResponse = await fetch(gateway, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': JSON_CONTENT_TYPE },\n body: sealed,\n });\n const opened = await openBytes(await relayResponse.text());\n if (opened === undefined) {\n return new Response(JSON.stringify({ error: failure }), { status: 502 });\n }\n let decoded: unknown;\n try {\n decoded = JSON.parse(decoder.decode(opened));\n } catch {\n decoded = undefined;\n }\n if (!isSealedHttpResponse(decoded)) {\n failure = 'The sealed HTTP response was malformed.';\n return new Response(JSON.stringify({ error: failure }), { status: 502 });\n }\n const body = decodeBase64(decoded.body);\n return new Response(decoded.status === 204 || decoded.status === 205 || decoded.status === 304 ? null : body, {\n status: decoded.status,\n headers: decoded.headers,\n });\n });\n },\n };\n}\n\n/**\n * The page's one sealed channel.\n *\n * A module singleton rather than an instance per caller, because the cockpit\n * and every plugin are bundled into the same page and all of them must share\n * one pair of nonce counters. Two transports over one handshake would each\n * start counting at zero and the receiver would reject the second as a replay.\n *\n * Inactive until a handshake completes, and inactive forever on the loopback\n * listener, so `sealedTransport.fetch` is a plain `fetch` there.\n */\nexport const sealedTransport = createSealedTransport();\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ function e(){let e=Promise.resolve();return{run(t){let n=e.then(t,t);return e=n.catch(()=>void 0),n}}}exports.createSerialQueue=e;
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["createSerialQueue","tail","result","task"],"sources":["../../../src/services/serialQueue/index.ts"],"sourcesContent":["/**\n * Runs async work strictly one at a time, in the order it was handed over.\n *\n * The sealed channel needs this on both directions and it is not an\n * optimisation. Sealing increments a nonce counter and opening enforces that\n * the counter strictly increases, so two overlapping operations are a\n * correctness bug rather than a race worth tolerating: two concurrent seals can\n * resolve out of order and reach the wire with descending counters, and two\n * concurrent opens can advance the high-water mark past a message that has not\n * been read yet, which the receiver then rejects as a replay.\n *\n * Deliberately not a general work pool. A pool with any concurrency above one\n * reintroduces exactly the reordering this exists to prevent.\n */\nexport interface SerialQueue {\n /** Resolves with the task's result once every task queued before it has settled. */\n run<T>(task: () => Promise<T>): Promise<T>;\n}\n\nexport function createSerialQueue(): SerialQueue {\n /** The tail of the chain. Rejections are absorbed so one failure cannot stall the rest. */\n let tail: Promise<unknown> = Promise.resolve();\n\n return {\n run<T>(task: () => Promise<T>): Promise<T> {\n const result = tail.then(task, task);\n tail = result.catch(() => undefined);\n return result;\n },\n };\n}\n"],"mappings":"AAmBA,SAAgBA,GAAiC,CAE/C,IAAIC,EAAyB,QAAQ,QAAQ,EAE7C,MAAO,CACL,IAAO,EAAoC,CACzC,IAAMC,EAASD,EAAK,KAAKE,EAAMA,CAAI,EAEnC,MADA,GAAOD,EAAO,UAAY,IAAA,EAAS,EAC5BA,CACT,CACF,CACF"}
@@ -0,0 +1,22 @@
1
+ //#region src/services/serialQueue/index.d.ts
2
+ /**
3
+ * Runs async work strictly one at a time, in the order it was handed over.
4
+ *
5
+ * The sealed channel needs this on both directions and it is not an
6
+ * optimisation. Sealing increments a nonce counter and opening enforces that
7
+ * the counter strictly increases, so two overlapping operations are a
8
+ * correctness bug rather than a race worth tolerating: two concurrent seals can
9
+ * resolve out of order and reach the wire with descending counters, and two
10
+ * concurrent opens can advance the high-water mark past a message that has not
11
+ * been read yet, which the receiver then rejects as a replay.
12
+ *
13
+ * Deliberately not a general work pool. A pool with any concurrency above one
14
+ * reintroduces exactly the reordering this exists to prevent.
15
+ */
16
+ export interface SerialQueue {
17
+ /** Resolves with the task's result once every task queued before it has settled. */
18
+ run<T>(task: () => Promise<T>): Promise<T>;
19
+ }
20
+ export declare function createSerialQueue(): SerialQueue;
21
+ //#endregion
22
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/services/serialQueue/index.ts"],"mappings":";;;;;;;;;;;;;;;iBAciB;;EAEf,IAAI,GAAG,YAAY,QAAQ,KAAK,QAAQ;;wBAG1B,qBAAqB"}
@@ -0,0 +1,2 @@
1
+ function e(){let e=Promise.resolve();return{run(t){let n=e.then(t,t);return e=n.catch(()=>void 0),n}}}export{e as createSerialQueue};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["createSerialQueue","tail","result","task"],"sources":["../../../src/services/serialQueue/index.ts"],"sourcesContent":["/**\n * Runs async work strictly one at a time, in the order it was handed over.\n *\n * The sealed channel needs this on both directions and it is not an\n * optimisation. Sealing increments a nonce counter and opening enforces that\n * the counter strictly increases, so two overlapping operations are a\n * correctness bug rather than a race worth tolerating: two concurrent seals can\n * resolve out of order and reach the wire with descending counters, and two\n * concurrent opens can advance the high-water mark past a message that has not\n * been read yet, which the receiver then rejects as a replay.\n *\n * Deliberately not a general work pool. A pool with any concurrency above one\n * reintroduces exactly the reordering this exists to prevent.\n */\nexport interface SerialQueue {\n /** Resolves with the task's result once every task queued before it has settled. */\n run<T>(task: () => Promise<T>): Promise<T>;\n}\n\nexport function createSerialQueue(): SerialQueue {\n /** The tail of the chain. Rejections are absorbed so one failure cannot stall the rest. */\n let tail: Promise<unknown> = Promise.resolve();\n\n return {\n run<T>(task: () => Promise<T>): Promise<T> {\n const result = tail.then(task, task);\n tail = result.catch(() => undefined);\n return result;\n },\n };\n}\n"],"mappings":"AAmBA,SAAgBA,GAAiC,CAE/C,IAAIC,EAAyB,QAAQ,QAAQ,EAE7C,MAAO,CACL,IAAO,EAAoC,CACzC,IAAMC,EAASD,EAAK,KAAKE,EAAMA,CAAI,EAEnC,MADA,GAAOD,EAAO,UAAY,IAAA,EAAS,EAC5BA,CACT,CACF,CACF"}
@@ -0,0 +1,3 @@
1
+ const e=`/index.html`,t=/^po\/[a-z0-9-]+\/v[1-9][0-9]*$/u,n=/^[0-9a-f]{64}$/u,r=/^[A-Za-z0-9_-]+$/u,i=/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u;function a(e,t){let n=Object.keys(e).sort();return n.length===t.length&&n.every((e,n)=>e===t[n])}function o(e,t){return typeof e==`number`&&Number.isSafeInteger(e)&&e>=t}function s(e){return typeof e!=`string`||e.length<2||!e.startsWith(`/`)||e.includes(`\\`)||e.includes(`\0`)||e.includes(`?`)||e.includes(`#`)||/[^\x21-\x7e]/u.test(e)||e.includes(`//`)||/%(?![0-9a-f]{2})/iu.test(e)||/%(?:2e|2f|5c)/iu.test(e)?!1:e.split(`/`).slice(1).every(e=>e!==``&&e!==`.`&&e!==`..`)}function c(e){let t=[...e.assets].sort((e,t)=>e.path<t.path?-1:+(e.path>t.path)).map(e=>`${e.path}\0${e.sha256}\0${String(e.byteLength)}\0${e.contentType}`);return[String(e.version),String(e.revision),String(e.builtAt),...t].join(`
2
+ `)}function l(e,n){if(!t.test(e))throw Error(`invalid attestation tag: ${e}`);if(!Array.isArray(n)||!n.every(e=>typeof e==`string`))throw Error(`invalid attestation fields for ${e}`);return new TextEncoder().encode(`${e}\n${JSON.stringify(n)}`)}function u(t,n,r){if(r===`web`)return n.has(e);if(r!==`native`||t.length!==1)return!1;let[i]=t;return i!==void 0&&i.path===`/composition.js`&&i.contentType===`text/javascript`&&i.byteLength<=4194304}function d(e,t=`web`){if(typeof e!=`object`||!e||!a(e,[`assets`,`builtAt`,`revision`,`version`]))return!1;let r=e;if(r.version!==2||!o(r.revision,1)||!o(r.builtAt,0)||!Array.isArray(r.assets)||r.assets.length===0)return!1;let c=/* @__PURE__ */ new Set;for(let e of r.assets){if(typeof e!=`object`||!e||!a(e,[`byteLength`,`contentType`,`path`,`sha256`]))return!1;let t=e;if(!s(t.path)||c.has(t.path)||typeof t.sha256!=`string`||!n.test(t.sha256)||!o(t.byteLength,0)||typeof t.contentType!=`string`||!i.test(t.contentType))return!1;c.add(t.path)}return u(r.assets,c,t)}function f(e,t=`web`){if(typeof e!=`object`||!e||!a(e,[`manifest`,`publicKey`,`signature`]))return!1;let n=e;return d(n.manifest,t)&&typeof n.signature==`string`&&n.signature.length===86&&r.test(n.signature)&&typeof n.publicKey==`string`&&n.publicKey.length<=512&&r.test(n.publicKey)}function p(e,t){return e.assets.find(e=>e.path===t)}function m(e,t){return p(e,t)?.sha256}exports.ATTESTATION_TAG=t,exports.BUNDLE_MANIFEST_ROUTE=`/bundle-manifest.json`,exports.BUNDLE_MANIFEST_VERSION=2,exports.NATIVE_COMPOSITION_MAX_BYTES=4194304,exports.NATIVE_ENTRY_CONTENT_TYPE=`text/javascript`,exports.NATIVE_ENTRY_PATH=`/composition.js`,exports.WEB_ENTRY_PATH=e,exports.assetFor=p,exports.attestationBytes=l,exports.canonicalManifest=c,exports.digestFor=m,exports.isBundleManifest=d,exports.isSignedBundleManifest=f;
3
+ //# sourceMappingURL=bundleManifest.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundleManifest.cjs","names":["WEB_ENTRY_PATH","ATTESTATION_TAG","SHA256","BASE64URL","CONTENT_TYPE","hasExactKeys","keys","value","expected","key","index","isSafeInteger","minimum","isSafeAssetPath","segment","canonicalManifest","lines","manifest","left","right","asset","attestationBytes","tag","fields","field","matchesProfile","profile","paths","assets","entry","isBundleManifest","candidate","valueAsset","isSignedBundleManifest","assetFor","path","digestFor"],"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\n/**\n * Which asset shape a manifest must have. A web bundle always serves `/index.html`; a native bundle is\n * exactly one composition module and never serves `/index.html`, so no manifest can pass both.\n */\nexport type BundleProfile = 'web' | 'native';\nexport const WEB_ENTRY_PATH = '/index.html';\nexport const NATIVE_ENTRY_PATH = '/composition.js';\nexport const NATIVE_ENTRY_CONTENT_TYPE = 'text/javascript';\n/** Largest native composition a sealed response can carry. */\nexport const NATIVE_COMPOSITION_MAX_BYTES = 4 * 1024 * 1024;\n/** Domain-separation tag for an attestation. Starts with `p`, so it can never collide with a `2\\n` manifest. */\nexport const ATTESTATION_TAG = /^po\\/[a-z0-9-]+\\/v[1-9][0-9]*$/u;\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\n/**\n * The exact bytes of a tagged attestation: `UTF-8(tag + \"\\n\" + JSON.stringify(fields))`. Fields are an\n * ordered list of strings, so both sides build the same bytes without relying on object key order.\n * Throws on a malformed tag or a non-string field.\n */\nexport function attestationBytes(tag: string, fields: readonly string[]): Uint8Array<ArrayBuffer> {\n if (!ATTESTATION_TAG.test(tag)) throw new Error(`invalid attestation tag: ${tag}`);\n if (!Array.isArray(fields) || !fields.every((field) => typeof field === 'string')) {\n throw new Error(`invalid attestation fields for ${tag}`);\n }\n return new TextEncoder().encode(`${tag}\\n${JSON.stringify(fields)}`);\n}\n\nfunction matchesProfile(assets: readonly BundleAsset[], paths: ReadonlySet<string>, profile: BundleProfile): boolean {\n if (profile === 'web') return paths.has(WEB_ENTRY_PATH);\n if (profile !== 'native' || assets.length !== 1) return false;\n const [entry] = assets;\n return (\n entry !== undefined &&\n entry.path === NATIVE_ENTRY_PATH &&\n entry.contentType === NATIVE_ENTRY_CONTENT_TYPE &&\n entry.byteLength <= NATIVE_COMPOSITION_MAX_BYTES\n );\n}\n\nexport function isBundleManifest(value: unknown, profile: BundleProfile = 'web'): 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 matchesProfile(candidate.assets as BundleAsset[], paths, profile);\n}\n\nexport function isSignedBundleManifest(value: unknown, profile: BundleProfile = 'web'): 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, profile) &&\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":"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"}
@@ -0,0 +1,62 @@
1
+ //#region src/types/bundleManifest.d.ts
2
+ /**
3
+ * A signed description of cockpit assets for an independently trusted verifier.
4
+ *
5
+ * A manifest does not let a browser SPA defend itself against the server or TLS
6
+ * edge that supplied its executable code. Consumers need a separately trusted
7
+ * bootstrap and key before these signatures become a delivery guarantee.
8
+ */
9
+ export declare const BUNDLE_MANIFEST_ROUTE = "/bundle-manifest.json";
10
+ export declare const BUNDLE_MANIFEST_VERSION = 2;
11
+ /**
12
+ * Which asset shape a manifest must have. A web bundle always serves `/index.html`; a native bundle is
13
+ * exactly one composition module and never serves `/index.html`, so no manifest can pass both.
14
+ */
15
+ export type BundleProfile = 'web' | 'native';
16
+ export declare const WEB_ENTRY_PATH = "/index.html";
17
+ export declare const NATIVE_ENTRY_PATH = "/composition.js";
18
+ export declare const NATIVE_ENTRY_CONTENT_TYPE = "text/javascript";
19
+ /** Largest native composition a sealed response can carry. */
20
+ export declare const NATIVE_COMPOSITION_MAX_BYTES: number;
21
+ /** Domain-separation tag for an attestation. Starts with `p`, so it can never collide with a `2\n` manifest. */
22
+ export declare const ATTESTATION_TAG: RegExp;
23
+ export interface BundleAsset {
24
+ /** Normalized absolute URL path, without a query or fragment. */
25
+ path: string;
26
+ /** Lowercase hex SHA-256 of the served bytes. */
27
+ sha256: string;
28
+ byteLength: number;
29
+ /** MIME type of the response, without parameters. */
30
+ contentType: string;
31
+ }
32
+ export interface BundleManifest {
33
+ version: typeof BUNDLE_MANIFEST_VERSION;
34
+ /** Monotonically increasing persistent signer revision. */
35
+ revision: number;
36
+ /** Unix time in milliseconds. */
37
+ builtAt: number;
38
+ assets: BundleAsset[];
39
+ }
40
+ export interface SignedBundleManifest {
41
+ manifest: BundleManifest;
42
+ /** Base64url IEEE P1363 ECDSA P-256 signature over the canonical serialization. */
43
+ signature: string;
44
+ /** Base64url SPKI of the signing key. Trust must be established out of band. */
45
+ publicKey: string;
46
+ }
47
+ /** The exact bytes signed by Node and verified by WebCrypto. */
48
+ export declare function canonicalManifest(manifest: BundleManifest): string;
49
+ /**
50
+ * The exact bytes of a tagged attestation: `UTF-8(tag + "\n" + JSON.stringify(fields))`. Fields are an
51
+ * ordered list of strings, so both sides build the same bytes without relying on object key order.
52
+ * Throws on a malformed tag or a non-string field.
53
+ */
54
+ export declare function attestationBytes(tag: string, fields: readonly string[]): Uint8Array<ArrayBuffer>;
55
+ export declare function isBundleManifest(value: unknown, profile?: BundleProfile): value is BundleManifest;
56
+ export declare function isSignedBundleManifest(value: unknown, profile?: BundleProfile): value is SignedBundleManifest;
57
+ /** The recorded asset for one path, or undefined if the manifest does not cover it. */
58
+ export declare function assetFor(manifest: BundleManifest, path: string): BundleAsset | undefined;
59
+ /** The recorded digest for one asset, or undefined if the manifest does not cover it. */
60
+ export declare function digestFor(manifest: BundleManifest, path: string): string | undefined;
61
+ //#endregion
62
+ //# sourceMappingURL=bundleManifest.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundleManifest.d.mts","names":[],"sources":["../../src/types/bundleManifest.ts"],"mappings":";;;;;;;;qBAQa;qBACA;;;;;YAMD;qBACC;qBACA;qBACA;;qBAEA;;qBAEA,iBAAe;iBAMX;;EAEf;;EAEA;EACA;;EAEA;;iBAGe;EACf,gBAAgB;;EAEhB;;EAEA;EACA,QAAQ;;iBAGO;EACf,UAAU;;EAEV;;EAEA;;;wBAwBc,kBAAkB,UAAU;;;;;;wBAe5B,iBAAiB,aAAa,4BAA4B,WAAW;wBAoBrE,iBAAiB,gBAAgB,UAAS,gBAAwB,SAAS;wBAsB3E,uBAAuB,gBAAgB,UAAS,gBAAwB,SAAS;;wBAgBjF,SAAS,UAAU,gBAAgB,eAAe;;wBAKlD,UAAU,UAAU,gBAAgB"}
@@ -0,0 +1,3 @@
1
+ const e=`/bundle-manifest.json`,t=2,n=`/index.html`,r=`/composition.js`,i=`text/javascript`,a=4194304,o=/^po\/[a-z0-9-]+\/v[1-9][0-9]*$/u,s=/^[0-9a-f]{64}$/u,c=/^[A-Za-z0-9_-]+$/u,l=/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u;function u(e,t){let n=Object.keys(e).sort();return n.length===t.length&&n.every((e,n)=>e===t[n])}function d(e,t){return typeof e==`number`&&Number.isSafeInteger(e)&&e>=t}function f(e){return typeof e!=`string`||e.length<2||!e.startsWith(`/`)||e.includes(`\\`)||e.includes(`\0`)||e.includes(`?`)||e.includes(`#`)||/[^\x21-\x7e]/u.test(e)||e.includes(`//`)||/%(?![0-9a-f]{2})/iu.test(e)||/%(?:2e|2f|5c)/iu.test(e)?!1:e.split(`/`).slice(1).every(e=>e!==``&&e!==`.`&&e!==`..`)}function p(e){let t=[...e.assets].sort((e,t)=>e.path<t.path?-1:+(e.path>t.path)).map(e=>`${e.path}\0${e.sha256}\0${String(e.byteLength)}\0${e.contentType}`);return[String(e.version),String(e.revision),String(e.builtAt),...t].join(`
2
+ `)}function m(e,t){if(!o.test(e))throw Error(`invalid attestation tag: ${e}`);if(!Array.isArray(t)||!t.every(e=>typeof e==`string`))throw Error(`invalid attestation fields for ${e}`);return new TextEncoder().encode(`${e}\n${JSON.stringify(t)}`)}function h(e,t,r){if(r===`web`)return t.has(n);if(r!==`native`||e.length!==1)return!1;let[i]=e;return i!==void 0&&i.path===`/composition.js`&&i.contentType===`text/javascript`&&i.byteLength<=4194304}function g(e,t=`web`){if(typeof e!=`object`||!e||!u(e,[`assets`,`builtAt`,`revision`,`version`]))return!1;let n=e;if(n.version!==2||!d(n.revision,1)||!d(n.builtAt,0)||!Array.isArray(n.assets)||n.assets.length===0)return!1;let r=/* @__PURE__ */ new Set;for(let e of n.assets){if(typeof e!=`object`||!e||!u(e,[`byteLength`,`contentType`,`path`,`sha256`]))return!1;let t=e;if(!f(t.path)||r.has(t.path)||typeof t.sha256!=`string`||!s.test(t.sha256)||!d(t.byteLength,0)||typeof t.contentType!=`string`||!l.test(t.contentType))return!1;r.add(t.path)}return h(n.assets,r,t)}function _(e,t=`web`){if(typeof e!=`object`||!e||!u(e,[`manifest`,`publicKey`,`signature`]))return!1;let n=e;return g(n.manifest,t)&&typeof n.signature==`string`&&n.signature.length===86&&c.test(n.signature)&&typeof n.publicKey==`string`&&n.publicKey.length<=512&&c.test(n.publicKey)}function v(e,t){return e.assets.find(e=>e.path===t)}function y(e,t){return v(e,t)?.sha256}export{o as ATTESTATION_TAG,e as BUNDLE_MANIFEST_ROUTE,t as BUNDLE_MANIFEST_VERSION,a as NATIVE_COMPOSITION_MAX_BYTES,i as NATIVE_ENTRY_CONTENT_TYPE,r as NATIVE_ENTRY_PATH,n as WEB_ENTRY_PATH,v as assetFor,m as attestationBytes,p as canonicalManifest,y as digestFor,g as isBundleManifest,_ as isSignedBundleManifest};
3
+ //# sourceMappingURL=bundleManifest.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundleManifest.mjs","names":["BUNDLE_MANIFEST_ROUTE","BUNDLE_MANIFEST_VERSION","WEB_ENTRY_PATH","NATIVE_ENTRY_PATH","NATIVE_ENTRY_CONTENT_TYPE","NATIVE_COMPOSITION_MAX_BYTES","ATTESTATION_TAG","SHA256","BASE64URL","CONTENT_TYPE","hasExactKeys","keys","value","expected","key","index","isSafeInteger","minimum","isSafeAssetPath","segment","canonicalManifest","lines","manifest","left","right","asset","attestationBytes","tag","fields","field","matchesProfile","profile","paths","assets","entry","isBundleManifest","candidate","valueAsset","isSignedBundleManifest","assetFor","path","digestFor"],"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\n/**\n * Which asset shape a manifest must have. A web bundle always serves `/index.html`; a native bundle is\n * exactly one composition module and never serves `/index.html`, so no manifest can pass both.\n */\nexport type BundleProfile = 'web' | 'native';\nexport const WEB_ENTRY_PATH = '/index.html';\nexport const NATIVE_ENTRY_PATH = '/composition.js';\nexport const NATIVE_ENTRY_CONTENT_TYPE = 'text/javascript';\n/** Largest native composition a sealed response can carry. */\nexport const NATIVE_COMPOSITION_MAX_BYTES = 4 * 1024 * 1024;\n/** Domain-separation tag for an attestation. Starts with `p`, so it can never collide with a `2\\n` manifest. */\nexport const ATTESTATION_TAG = /^po\\/[a-z0-9-]+\\/v[1-9][0-9]*$/u;\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\n/**\n * The exact bytes of a tagged attestation: `UTF-8(tag + \"\\n\" + JSON.stringify(fields))`. Fields are an\n * ordered list of strings, so both sides build the same bytes without relying on object key order.\n * Throws on a malformed tag or a non-string field.\n */\nexport function attestationBytes(tag: string, fields: readonly string[]): Uint8Array<ArrayBuffer> {\n if (!ATTESTATION_TAG.test(tag)) throw new Error(`invalid attestation tag: ${tag}`);\n if (!Array.isArray(fields) || !fields.every((field) => typeof field === 'string')) {\n throw new Error(`invalid attestation fields for ${tag}`);\n }\n return new TextEncoder().encode(`${tag}\\n${JSON.stringify(fields)}`);\n}\n\nfunction matchesProfile(assets: readonly BundleAsset[], paths: ReadonlySet<string>, profile: BundleProfile): boolean {\n if (profile === 'web') return paths.has(WEB_ENTRY_PATH);\n if (profile !== 'native' || assets.length !== 1) return false;\n const [entry] = assets;\n return (\n entry !== undefined &&\n entry.path === NATIVE_ENTRY_PATH &&\n entry.contentType === NATIVE_ENTRY_CONTENT_TYPE &&\n entry.byteLength <= NATIVE_COMPOSITION_MAX_BYTES\n );\n}\n\nexport function isBundleManifest(value: unknown, profile: BundleProfile = 'web'): 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 matchesProfile(candidate.assets as BundleAsset[], paths, profile);\n}\n\nexport function isSignedBundleManifest(value: unknown, profile: BundleProfile = 'web'): 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, profile) &&\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":"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"}
@@ -0,0 +1,2 @@
1
+ const e=2**32,t=`po/sealed/c2s/v1`,n=`po/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-po-sealed`,exports.SEALED_CLIENT_KEY_HEADER=`x-po-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":["MAX_MESSAGES_PER_KEY","CLIENT_TO_SERVER_INFO","SERVER_TO_CLIENT_INFO","infoFor","direction","isSealedEnvelope","value","candidate","describeSealedFailure","failure"],"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 = 'po/sealed/c2s/v1';\nexport const SERVER_TO_CLIENT_INFO = 'po/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-po-channel-key';\n/** Header carrying a sealed request body's envelope on a non-JSON request. */\nexport const SEALED_BODY_HEADER = 'x-po-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,MAoBaA,EAAuB,GAAK,GAQ5BC,EAAwB,mBACxBC,EAAwB,mBAIrC,SAAgBC,EAAQ,EAAoC,CAC1D,OAAOC,IAAc,MAAQH,EAAwBC,CACvD,CAgBA,SAAgBG,EAAiB,EAAyC,CACxE,GAAI,OAAOC,GAAU,WAAYA,EAAgB,MAAO,GACxD,IAAMC,EAAYD,EAClB,OAAOC,EAAU,IAAA,GAAwB,OAAOA,EAAU,GAAM,UAAY,OAAOA,EAAU,GAAM,QACrG,CAeA,SAAgBC,EAAsB,EAAgC,CACpE,OAAQC,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,76 @@
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
+ export declare const SEALED_VERSION = 1;
22
+ /** Fragment key in the pairing URL carrying the host's ephemeral public key. */
23
+ export declare const SEALED_KEY_PARAM = "k";
24
+ /** AES-GCM standard nonce length. Longer is non-standard, shorter is unsafe. */
25
+ export declare const NONCE_BYTES = 12;
26
+ /** Random per-channel prefix, so two channels never share a nonce space. */
27
+ export declare const NONCE_PREFIX_BYTES = 4;
28
+ /** Monotonic per-direction counter filling the rest of the nonce. */
29
+ export 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
+ export 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
+ export declare const CLIENT_TO_SERVER_INFO = "po/sealed/c2s/v1";
46
+ export declare const SERVER_TO_CLIENT_INFO = "po/sealed/s2c/v1";
47
+ export type SealedDirection = 'c2s' | 's2c';
48
+ export 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
+ export 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
+ export declare function isSealedEnvelope(value: unknown): value is SealedEnvelope;
63
+ /** Header naming the client's ephemeral public key during the handshake. */
64
+ export declare const SEALED_CLIENT_KEY_HEADER = "x-po-channel-key";
65
+ /** Header carrying a sealed request body's envelope on a non-JSON request. */
66
+ export declare const SEALED_BODY_HEADER = "x-po-sealed";
67
+ export 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
+ export declare function describeSealedFailure(failure: SealedFailure): string;
75
+ //#endregion
76
+ //# sourceMappingURL=sealedChannel.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sealedChannel.d.mts","names":[],"sources":["../../src/types/sealedChannel.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;qBAoBa;;qBAGA;;qBAGA;;qBAEA;;qBAEA;;;;;;;;;qBAUA;;;;;;;qBAQA;qBACA;YAED;wBAEI,QAAQ,WAAW;;;;;;;iBAUlB;EACf;;EAEA;;EAEA;;wBAGc,iBAAiB,iBAAiB,SAAS;;qBAO9C;;qBAEA;YAED;;;;;;;wBAQI,sBAAsB,SAAS"}
@@ -0,0 +1,2 @@
1
+ const e=1,t=`k`,n=12,r=4,i=8,a=2**32,o=`po/sealed/c2s/v1`,s=`po/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-po-channel-key`,d=`x-po-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