diff --git a/.gitignore b/.gitignore index a8bc398e3..ba8ffdce0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-server/ build/ *.tsbuildinfo .env diff --git a/apps/lch-reference/AGENTS.md b/apps/lch-reference/AGENTS.md new file mode 100644 index 000000000..da1c81f82 --- /dev/null +++ b/apps/lch-reference/AGENTS.md @@ -0,0 +1,10 @@ +# ts-stack agent instructions + +This project follows the repository-wide [agent instructions](../../AGENTS.md) +and [contribution policy](../../CONTRIBUTING.md). Read and follow both files +before changing anything in this directory. + +Do not add package-local agent or contribution conventions. Put +package-specific technical information in the package README, `docs/`, +`specs/`, or the applicable operator guide, and propose shared policy at the +repository root. diff --git a/apps/lch-reference/DEPLOYMENT.md b/apps/lch-reference/DEPLOYMENT.md new file mode 100644 index 000000000..b73af895b --- /dev/null +++ b/apps/lch-reference/DEPLOYMENT.md @@ -0,0 +1,202 @@ +# LCH reference deployment + +## Roles and data flow + +```mermaid +flowchart LR + C[Creator wizard] -->|plaintext + rights interests| I[Issuer service] + I -->|ciphertext| H[CHIRP / UHRP / HTTP content hosts] + I -->|detached LCH + signed Offer| P[Player] + P -->|signed License Request| I + I -->|signed Quote + Demands| P + P -->|createAction: one Atomic BEEF| BW[Buyer BRC-100 wallet] + BW -->|BRC-29 output A| WA[Recording-controller wallet] + BW -->|BRC-29 output B| WB[Composition-controller wallet] + P -->|signed Payment Delivery| DA[Drummer delivery service] + P -->|signed Payment Delivery| DB[Composer delivery service] + P -->|authorized Delivery| DS[Durable Delivery provider] + P -->|exact Atomic BEEF| TE[Transaction evidence provider] + DA -->|internalizeAction| WA + DB -->|internalizeAction| WB + DS -->|authenticated late retrieval| DA + TE -->|signed accepted evidence| P + DA -->|signed Receipt| P + DB -->|signed Receipt| P + P -->|Atomic BEEF + one proof per Demand| I + I -->|signed License + BRC-78 grants| P + P -->|authenticated range reads| H +``` + +Money is received by the Payee wallets named in the signed Payment Demands. `WalletPaymentReceiver` derives the expected receiving key with BRC-29, validates the exact finalized output, and calls that Payee wallet's BRC-100 `internalizeAction`. The issuer only receives value when it is explicitly one of the Payees. The reference split is 7 satoshis to the recording controller and 5 satoshis to the composition controller. Under authorized-output settlement, License issuance can precede the Payee wallet call, but the value is still locked to the exact Payee-derived script; the durable provider retains the Delivery so that wallet can internalize the same output later. + +The issuer endpoint is `${PUBLIC_BASE_URL}/api/lch`. The two reference Payees deliberately publish different delivery paths, `${PUBLIC_BASE_URL}/api/lch/payees/recording` and `${PUBLIC_BASE_URL}/api/lch/payees/composition`. The authorized-output fixture also exposes `${PUBLIC_BASE_URL}/api/lch/evidence`, `${PUBLIC_BASE_URL}/api/lch/delivery-store`, and `${PUBLIC_BASE_URL}/api/lch/delivery-retrieval`. They share a process and fixture issuer identity only to keep every role runnable and inspectable. A deployed Demand can name `https://payments.drummer.example/lch`, authorize `https://availability.drummer.example/lch`, and use `https://processor.example/evidence`, while the downstream work uses `https://licenses.publisher.example/lch`. None of those origins receives or controls another role's wallet merely because the evidence appears in one completion. + +`receipt-complete-v1` keeps the narrowest trust boundary: no License until the Payee wallet validates, internalizes, and signs. `authorized-output-v1` improves post-payment availability by letting the Payee pre-authorize an exact destination plus independent evidence and storage providers. It exposes more linkable metadata, depends on those providers, accepts processor policy before mining, and can release keys before wallet internalization. An offline Payee without that explicit Authorization remains pending. Production UIs should present that choice to the Payee when configuring its Demand service, not silently select fallback for buyers. + +## HTTP surface + +The executable Node server exposes: + +| Method | Path | Purpose | +| ------ | ----------------------------- | --------------------------------------------------------------------------------------------- | +| `GET` | `/api/health` | Wallet mode and acquisition endpoint | +| `POST` | `/api/assets` | Creator publication input (`name`, `mediaType`, base64 bytes); returns IDs and `lchBase64url` | +| `POST` | `/api/lch` | Issuer preflight, Quote, completion, and recovery | +| `POST` | `/api/lch/payees/{interest}` | Independently routed Payee preflight and Payment Delivery | +| `POST` | `/api/lch/evidence` | Exact-transaction evaluation and signed accepted evidence | +| `POST` | `/api/lch/delivery-store` | Durable signed Delivery storage and retention acknowledgement | +| `POST` | `/api/lch/delivery-retrieval` | Payee-signed authenticated retrieval of its stored Delivery | +| `GET` | `/content/{sha256}` | Detached ciphertext, including one HTTP byte range | +| `GET` | `/*` | Reference workbench and third-party notices | + +The endpoints accept only their role-appropriate exact media types implemented by `LCHHttpServer`. Bodies are bounded and error responses use stable LCH error codes. Sending a drummer Demand to the composition endpoint, a Delivery to the evidence endpoint, or a retrieval request signed by another identity fails even in the collapsed fixture topology. + +Publication example: + +```sh +curl -H 'content-type: application/json' \ + --data '{"name":"clip.wav","mediaType":"audio/wav","bytesBase64":"..."}' \ + https://lch.example/api/assets +``` + +## Wallet module + +Without `LCH_WALLET_MODULE`, the server reports `walletMode: "fixture"`. A connected deployment sets it to a self-contained ESM module exporting: + +```js +export async function createLCHWallets() { + return { + issuerWallet: await openBRC100Wallet('issuer'), + recordingWallet: await openBRC100Wallet('recording-controller'), + compositionWallet: await openBRC100Wallet('composition-controller') + } +} +``` + +Every value must implement the BRC-100 `WalletInterface`. The issuer wallet signs Offers, Quotes, Licenses, and BRC-78 key envelopes. The two Payee wallets sign their Demands and Receipts and receive funds through `internalizeAction`. A player supplies its own `WalletClient` or other `WalletInterface` to `ReferenceLCHClient`; its `createAction` is the only transaction-creation boundary. + +The module belongs in the operator's secret-bearing runtime, not in the public image. It may open local wallet-toolbox instances, connect to separately isolated BRC-100 wallet services, or wrap another conforming wallet substrate. Run each financial role with an independently controlled identity in deployments that require separate accounting or authority. A federated deployment normally runs a separate wallet module and Payment Ledger beside each Payee endpoint rather than loading every wallet into the issuer process. + +## Content storage + +`ReferenceContentStore` keeps ciphertext in process so the complete protocol can run with no external dependency. A deployed issuer replaces it at the `ContentSink`/`ContentSource` boundary: + +- `CHIRPContentSink` publishes chunked, merklized ciphertext and returns a `chirp://` locator. +- `UHRPContentSink` preserves the existing UHRP publication path. +- `UniversalContentSource` resolves CHIRP, UHRP, and bounded HTTPS locators and verifies the exact ciphertext digest and length declared by the LCH Asset Body. + +The LCH header, Offer, Quote, License, and content locator remain independent of which conforming host retains the ciphertext. Multiple complete-host locators can provide redundancy. A current BRC-167 UHRP root advertisement always means complete closure hosting; partial CHIRP coverage requires a future coverage profile and must not be represented as an ordinary complete-host advertisement. + +## Reference boundaries to replace + +The server's protocol handlers are reusable, but its default state is deliberately +visible and process-local. `/api/health` reports both `walletMode` and +`contentAdapter` so an operator can reject fixture configuration during rollout. + +| Boundary | Reference behavior | Durable deployment | +| ------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Wallets | Deterministic fixture wallets unless `LCH_WALLET_MODULE` is set | Independent production BRC-100 wallet per financial role | +| Ciphertext | `ReferenceContentStore` and `/content/{sha256}` in process memory | `CHIRPContentSink`, `UHRPContentSink`, or an application `ContentSink`; bounded `UniversalContentSource` for reads | +| Assets and Offers | In-process maps | Transactional asset, Offer, policy, wrapped-CEK, and locator records | +| Requests, Quotes, Licenses, recovery | In-process maps | Transactional issuer store indexed by Request ID through `recoveryUntil` | +| Payment Ledger | Receiver default is process-local | Atomic durable Demand-to-transaction claim shared by Payee replicas | +| Authorizations and evidence | In-process claims | Atomic Authorization-to-transaction claims with signed policy results | +| Delivery retention | In-process Delivery map | Durable exact bytes, authenticated retrieval, expiry enforcement, backup, and restore | +| Browser license store | Workbench state | `IndexedDBLicenseStore` or an application store with protected key material | + +Do not expose `/api/assets` until its publication input is authenticated, +authorized, rate-limited, and connected to durable content and issuer stores. +The route accepts base64 input for interoperability convenience; a large-media +service should stream or stage plaintext through a bounded creator workflow and +send only ciphertext to storage providers. + +## Deployment shapes + +The single-process server is the smallest executable topology. It contains the static workbench, issuer handlers, independently routed Payee handlers, and reference content store. It is appropriate for protocol development and interoperability testing. + +A durable topology separates five concerns: + +1. Stateless issuer/API replicas terminate the Offer endpoint and persist Requests, Quotes, completion state, Licenses, and wrapped content-encryption keys. +2. Every Payee operates its signed Demand endpoint, atomic Payment Ledger, Demand and Authorization state, and BRC-100 receiving wallet independently. +3. Authorized Delivery providers atomically store exact signed Deliveries through their promised recovery deadlines; evidence providers atomically bind each Authorization to one accepted transaction under the named policy. +4. The buyer persists the funded Atomic BEEF, signed Deliveries, Receipts, Authorizations, and fallback evidence before fan-out and until License recovery completes or `recoveryUntil` passes. +5. CHIRP/UHRP providers retain ciphertext; the issuer retains only locators and verified metadata. + +The Payment Ledger and Authorization-to-transaction claims must be atomic across replicas. The same Demand and transaction must return the same Receipt or evidence; a second transaction for that Demand or Authorization must fail. Stored Deliveries must survive process and zone loss through `availableUntil`. Content-encryption keys and wallet credentials require encryption at rest, access separation, backup, rotation, and audit controls appropriate to the deployment. + +The reference client records the wallet result as **finalized** only. The initial authorized-output evidence provider can separately sign **accepted** under `signed-processor-acceptance-v1`; it does not claim broadcast or mined state. A mined profile needs explicit SPV evidence. If direct and authorized fallback delivery fail after finalization, the application retains the transaction and all partial proofs and presents a pending settlement through `recoveryUntil`; it does not offer a second purchase action. + +`LCHAcquisitionTransport` lets a buyer route those same operations through an application-owned asynchronous inbox or message-box adapter. The BRC-170 v1 portable wire binding remains deterministic-CBOR HTTP. A gateway can enqueue internally while preserving the exact acknowledgement, identity, retention, and recovery contract. A native message-box protocol needs a separately registered profile defining addressing, authentication, correlation, reply polling, expiry, retention, and replay behavior; the adapter cannot silently replace signed Deliveries, Receipts, Authorizations, or provider evidence. + +## Runtime configuration + +| Variable | Default | Production meaning | +| -------------------------- | -------------------------- | ---------------------------------------------------------- | +| `PORT` | `4173` | Container listener port | +| `LCH_PUBLIC_BASE_URL` | `http://127.0.0.1:${PORT}` | Exact public HTTPS origin used in every generated endpoint | +| `LCH_STATIC_DIR` | Built `dist/` directory | Workbench and notice files served by the Node adapter | +| `LCH_WALLET_MODULE` | Unset, fixture mode | Secret-mounted ESM wallet factory described above | +| `LCH_RECORDING_SATOSHIS` | `7` | Demonstration recording-controller amount | +| `LCH_COMPOSITION_SATOSHIS` | `5` | Demonstration composition-controller amount | + +The two amount variables configure the fixed demonstration policy, not a +catalogue pricing system. Production applications should derive signed duties +and Demands from their durable rights and Offer records. If the public origin +changes, issue new objects that name the new endpoints; a proxy rewrite cannot +change already signed endpoint values. + +Terminate TLS at a trusted ingress, preserve request bodies byte-for-byte, +bound request and response time, and route each financial role to the process +that owns its identity and ledger. CORS headers allow browser transport but do +not authenticate creators, buyers, Payees, providers, or operators. + +## Container reference + +Build from the TS Stack repository root: + +```sh +docker build -f apps/lch-reference/Dockerfile -t lch-reference . +docker run --read-only --tmpfs /tmp -p 4173:4173 \ + -e LCH_PUBLIC_BASE_URL=https://lch.example \ + -e LCH_WALLET_MODULE=/run/lch-wallets/operator-wallets.mjs \ + -v /operator/lch-wallets:/run/lch-wallets:ro \ + lch-reference +``` + +Terminate TLS at the ingress, set `LCH_PUBLIC_BASE_URL` to the public HTTPS origin, and restrict publication to authenticated creator/admin traffic before exposing `/api/assets`. The reference route deliberately contains no account system because creator authentication is an application policy, not an LCH wire object. + +The image does not include a durable database or CHIRP server. Mounting a real +wallet module changes `walletMode` but does not replace the memory stores. An +operator can use the container unchanged for conformance and interoperability, +or use the Fetch-compatible handlers and storage interfaces in an +application-specific service that supplies the durable boundaries above. + +## Rollout and rollback + +1. Build and test the exact source commit and retain `dist/licenses/` with the + browser and server artifacts. +2. Provision durable stores, CHIRP/UHRP retention, independent wallet modules, + role credentials, endpoint DNS, TLS, backups, and expiry/renewal monitors. +3. Deploy without public publication traffic. Require `/api/health` to report + `status: "ready"`, `walletMode: "connected"`, the expected public endpoints, + and a non-memory content adapter in the application-specific health result. +4. Publish and retrieve a small detached LCH, verify the CHIRP closure, and run + one acquisition through every enabled settlement profile. +5. Simulate a lost completion response and recover by Request ID. Restart the + issuer, each Payee, and providers; then retrieve and internalize a retained + Delivery exactly once. +6. Enable creator traffic, then buyer traffic, while watching pending + settlement, conflicting-claim, content-hash, wallet, retention, and recovery + signals. + +For rollback, stop new Quotes and creator publication first. Keep the old +issuer, Payee, evidence, Delivery, and content endpoints available through the +latest outstanding `recoveryUntil` and storage retention deadlines. Never roll +back by discarding funded buyer state or by pointing signed objects at a new +origin. Restore the exact compatible service and durable data, complete or +recover existing Requests, then change catalogue traffic to newly issued +Offers. + +The repository-wide [production CHIRP and LCH guide](../../docs/guides/chirp-lch-production.md) +adds consumer code, persistence ownership, failure handling, security, +observability, conformance, and agent integration checklists. diff --git a/apps/lch-reference/Dockerfile b/apps/lch-reference/Dockerfile new file mode 100644 index 000000000..b535c2bac --- /dev/null +++ b/apps/lch-reference/Dockerfile @@ -0,0 +1,15 @@ +FROM node:24-bookworm-slim AS build +RUN corepack enable +WORKDIR /workspace +COPY . . +RUN pnpm install --frozen-lockfile --ignore-scripts +RUN pnpm --filter @bsv/lch build && pnpm --filter lch-reference-app build + +FROM node:24-bookworm-slim +ENV NODE_ENV=production PORT=4173 +WORKDIR /app +COPY --from=build /workspace/apps/lch-reference/dist ./dist +COPY --from=build /workspace/apps/lch-reference/dist-server ./dist-server +EXPOSE 4173 +USER node +CMD ["node", "dist-server/nodeServer.js"] diff --git a/apps/lch-reference/Dockerfile.dockerignore b/apps/lch-reference/Dockerfile.dockerignore new file mode 100644 index 000000000..fdcd22aca --- /dev/null +++ b/apps/lch-reference/Dockerfile.dockerignore @@ -0,0 +1,7 @@ +.git +**/node_modules +**/dist +**/dist-server +**/coverage +**/.turbo +**/.DS_Store diff --git a/apps/lch-reference/LICENSE.txt b/apps/lch-reference/LICENSE.txt new file mode 100644 index 000000000..15e819500 --- /dev/null +++ b/apps/lch-reference/LICENSE.txt @@ -0,0 +1,58 @@ +Open BSV License Version 6 – granted by BSV Association, Alpenstrasse 15, 6300 +Zug, Switzerland (CHE-427.008.338) ("Licensor"), to you as a user (henceforth +"You", "User" or "Licensee"). + +For the purposes of this license, the definitions below have the following +meanings: + +"Bitcoin Protocol" means the protocol implementation, cryptographic rules, +network protocols, and consensus mechanisms in the Bitcoin White Paper as +described here https://protocol.bsvblockchain.org. + +"Bitcoin White Paper" means the paper entitled 'Bitcoin: A Peer-to-Peer +Electronic Cash System' published by 'Satoshi Nakamoto' in October 2008. + +"BSV Blockchain" means: + + (a) the Bitcoin blockchain containing block height #556767 with the hash + "000000000000000001d956714215d96ffc00e0afda4cd0a96c96f8d802b1662b" and + that contains the longest honest persistent chain of blocks which has been + produced in a manner which is consistent with the rules set forth in the + Network Access Rules; and + (b) the test blockchains that contain the longest honest persistent chains of + blocks which has been produced in a manner which is consistent with the + rules set forth in the Network Access Rules. + +"Network Access Rules" or "Rules" means the set of rules regulating the +relationship between BSV Association and the nodes on BSV based on the Bitcoin +Protocol rules and those set out in the Bitcoin White Paper, and available here +https://bsvblockchain.org/network-access-rules. + +"Software" means the software the subject of this license, including any/all +intellectual property rights therein and associated documentation files. + +BSV Association grants permission, free of charge and on a non-exclusive basis +to any person obtaining a copy of the Software to deal in the Software, including +without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to and conditioned upon the following +conditions: + +1 - The text "© BSV Association", and this license shall be included in all +copies or substantial portions of the Software. + +2 - The Software, and any software that is derived from the Software or parts +thereof, may only be used exclusively on the BSV Blockchain. + +For the avoidance of doubt, this license is granted subject to and conditioned +upon your compliance with these terms only and is limited to uses on the BSV +Blockchain. Any exercise of rights not compliant with these terms including +use not for the BSV Blockchain is deemed outside the scope of the license. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES REGARDING ENTITLEMENT, +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS THEREOF BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/BIP-0032-BSD-2-Clause.txt b/apps/lch-reference/LICENSES/BIP-0032-BSD-2-Clause.txt new file mode 100644 index 000000000..5e612603b --- /dev/null +++ b/apps/lch-reference/LICENSES/BIP-0032-BSD-2-Clause.txt @@ -0,0 +1,28 @@ +BIP 32: Hierarchical Deterministic Wallets + +Author: Pieter Wuille +License: BSD-2-Clause + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above attribution, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above attribution, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Upstream licensing statement: +https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki diff --git a/apps/lch-reference/LICENSES/BIP-0039-MIT.txt b/apps/lch-reference/LICENSES/BIP-0039-MIT.txt new file mode 100644 index 000000000..aabb0ab01 --- /dev/null +++ b/apps/lch-reference/LICENSES/BIP-0039-MIT.txt @@ -0,0 +1,26 @@ +BIP 39: Mnemonic code for generating deterministic keys + +Authors: Marek Palatinus, Pavol Rusnak, Aaron Voisine, and Sean Bowe +License: MIT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above attribution, this permission notice and the statement that BIP 39 +falls under the MIT License shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Upstream licensing statement: +https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki diff --git a/apps/lch-reference/LICENSES/Open-BSV-License-4-standard-pre-2026.txt b/apps/lch-reference/LICENSES/Open-BSV-License-4-standard-pre-2026.txt new file mode 100644 index 000000000..64a88f890 --- /dev/null +++ b/apps/lch-reference/LICENSES/Open-BSV-License-4-standard-pre-2026.txt @@ -0,0 +1,28 @@ +Open BSV License version 4 + +Copyright (c) 2023 BSV Blockchain Association ("Bitcoin Association") + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +1 - The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +2 - The Software, and any software that is derived from the Software or parts thereof, +can only be used on the Bitcoin SV blockchains. The Bitcoin SV blockchains are defined, +for purposes of this license, as the Bitcoin blockchain containing block height #556767 +with the hash "000000000000000001d956714215d96ffc00e0afda4cd0a96c96f8d802b1662b" and +that contains the longest persistent chain of blocks accepted by this Software and which are valid under the rules set forth in the Bitcoin white paper (S. Nakamoto, Bitcoin: A Peer-to-Peer Electronic Cash System, posted online October 2008) and the latest version of this Software available in this repository or another repository designated by Bitcoin Association, +as well as the test blockchains that contain the longest persistent chains of blocks accepted by this Software and which are valid under the rules set forth in the Bitcoin whitepaper (S. Nakamoto, Bitcoin: A Peer-to-Peer Electronic Cash System, posted online October 2008) and the latest version of this Software available in this repository, or another repository designated by Bitcoin Association + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/Teranode-Open-BSV-License-6.txt b/apps/lch-reference/LICENSES/Teranode-Open-BSV-License-6.txt new file mode 100644 index 000000000..15e819500 --- /dev/null +++ b/apps/lch-reference/LICENSES/Teranode-Open-BSV-License-6.txt @@ -0,0 +1,58 @@ +Open BSV License Version 6 – granted by BSV Association, Alpenstrasse 15, 6300 +Zug, Switzerland (CHE-427.008.338) ("Licensor"), to you as a user (henceforth +"You", "User" or "Licensee"). + +For the purposes of this license, the definitions below have the following +meanings: + +"Bitcoin Protocol" means the protocol implementation, cryptographic rules, +network protocols, and consensus mechanisms in the Bitcoin White Paper as +described here https://protocol.bsvblockchain.org. + +"Bitcoin White Paper" means the paper entitled 'Bitcoin: A Peer-to-Peer +Electronic Cash System' published by 'Satoshi Nakamoto' in October 2008. + +"BSV Blockchain" means: + + (a) the Bitcoin blockchain containing block height #556767 with the hash + "000000000000000001d956714215d96ffc00e0afda4cd0a96c96f8d802b1662b" and + that contains the longest honest persistent chain of blocks which has been + produced in a manner which is consistent with the rules set forth in the + Network Access Rules; and + (b) the test blockchains that contain the longest honest persistent chains of + blocks which has been produced in a manner which is consistent with the + rules set forth in the Network Access Rules. + +"Network Access Rules" or "Rules" means the set of rules regulating the +relationship between BSV Association and the nodes on BSV based on the Bitcoin +Protocol rules and those set out in the Bitcoin White Paper, and available here +https://bsvblockchain.org/network-access-rules. + +"Software" means the software the subject of this license, including any/all +intellectual property rights therein and associated documentation files. + +BSV Association grants permission, free of charge and on a non-exclusive basis +to any person obtaining a copy of the Software to deal in the Software, including +without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to and conditioned upon the following +conditions: + +1 - The text "© BSV Association", and this license shall be included in all +copies or substantial portions of the Software. + +2 - The Software, and any software that is derived from the Software or parts +thereof, may only be used exclusively on the BSV Blockchain. + +For the avoidance of doubt, this license is granted subject to and conditioned +upon your compliance with these terms only and is limited to uses on the BSV +Blockchain. Any exercise of rights not compliant with these terms including +use not for the BSV Blockchain is deemed outside the scope of the license. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES REGARDING ENTITLEMENT, +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS THEREOF BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/aes-gcm.js-ISC.txt b/apps/lch-reference/LICENSES/aes-gcm.js-ISC.txt new file mode 100644 index 000000000..bf7a54dbe --- /dev/null +++ b/apps/lch-reference/LICENSES/aes-gcm.js-ISC.txt @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2019 Taner Mansur + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/apps/lch-reference/LICENSES/bitcoin-sv-879fc8b4.txt b/apps/lch-reference/LICENSES/bitcoin-sv-879fc8b4.txt new file mode 100644 index 000000000..58914eb52 --- /dev/null +++ b/apps/lch-reference/LICENSES/bitcoin-sv-879fc8b4.txt @@ -0,0 +1,86 @@ +Open BSV License Version 5 – granted by BSV Association, Grafenauweg 6, 6300 +Zug, Switzerland (CHE-427.008.338) ("Licensor"), to you as a user (henceforth +"You", "User" or "Licensee"). + +For the purposes of this license, the definitions below have the following +meanings: + +"Bitcoin Protocol" means the protocol implementation, cryptographic rules, +network protocols, and consensus mechanisms in the Bitcoin White Paper as +described here https://protocol.bsvblockchain.org. + +"Bitcoin White Paper" means the paper entitled 'Bitcoin: A Peer-to-Peer +Electronic Cash System' published by 'Satoshi Nakamoto' in October 2008. + +"BSV Blockchains" means: + (a) the Bitcoin blockchain containing block height #556767 with the hash + "000000000000000001d956714215d96ffc00e0afda4cd0a96c96f8d802b1662b" and + that contains the longest honest persistent chain of blocks which has been + produced in a manner which is consistent with the rules set forth in the + Network Access Rules; and + (b) the test blockchains that contain the longest honest persistent chains of + blocks which has been produced in a manner which is consistent with the + rules set forth in the Network Access Rules. + +"Network Access Rules" or "Rules" means the set of rules regulating the +relationship between BSV Association and the nodes on BSV based on the Bitcoin +Protocol rules and those set out in the Bitcoin White Paper, and available here +https://bsvblockchain.org/network-access-rules. + +"Software" means the software the subject of this licence, including any/all +intellectual property rights therein and associated documentation files. + +BSV Association grants permission, free of charge and on a non-exclusive and +revocable basis, to any person obtaining a copy of the Software to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to and conditioned upon the following conditions: + +1 - The text "© BSV Association," and this license shall be included in all +copies or substantial portions of the Software. +2 - The Software, and any software that is derived from the Software or parts +thereof, must only be used on the BSV Blockchains. + +For the avoidance of doubt, this license is granted subject to and conditioned +upon your compliance with these terms only. In the event of non-compliance, the +license shall extinguish and you can be enjoined from violating BSV's +intellectual property rights (incl. damages and similar related claims). + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES REGARDING ENTITLEMENT, +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS THEREOF BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +Version 0.1.1 of the Bitcoin SV software, and prior versions of software upon +which it was based, were licensed under the MIT License, which is included below. + +The MIT License (MIT) + +Copyright (c) 2009-2010 Satoshi Nakamoto +Copyright (c) 2009-2015 Bitcoin Developers +Copyright (c) 2009-2017 The Bitcoin Core developers +Copyright (c) 2017 The Bitcoin ABC developers +Copyright (c) 2018 Bitcoin Association for BSV +Copyright (c) 2023 BSV Association + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/bn.js-MIT.txt b/apps/lch-reference/LICENSES/bn.js-MIT.txt new file mode 100644 index 000000000..c328f0401 --- /dev/null +++ b/apps/lch-reference/LICENSES/bn.js-MIT.txt @@ -0,0 +1,19 @@ +Copyright Fedor Indutny, 2015. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/lch-reference/LICENSES/brorand-MIT.txt b/apps/lch-reference/LICENSES/brorand-MIT.txt new file mode 100644 index 000000000..a3d4b0d36 --- /dev/null +++ b/apps/lch-reference/LICENSES/brorand-MIT.txt @@ -0,0 +1,21 @@ +This software is licensed under the MIT License. + +Copyright Fedor Indutny, 2014. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE diff --git a/apps/lch-reference/LICENSES/bsv-1.5.6-MIT.txt b/apps/lch-reference/LICENSES/bsv-1.5.6-MIT.txt new file mode 100644 index 000000000..365c235ca --- /dev/null +++ b/apps/lch-reference/LICENSES/bsv-1.5.6-MIT.txt @@ -0,0 +1,36 @@ +Copyright (c) 2018-2019 Yours Inc. + +Copyright (c) 2013-2017 BitPay, Inc. + +Parts of this software are based on Bitcoin Core +Copyright (c) 2009-2015 The Bitcoin Core developers + +Parts of this software are based on fullnode +Copyright (c) 2014 Ryan X. Charles +Copyright (c) 2014 reddit, Inc. + +Parts of this software are based on BitcoinJS +Copyright (c) 2011 Stefan Thomas + +Parts of this software are based on BitcoinJ +Copyright (c) 2011 Google Inc. + +Copyright (c) 2009 Satoshi Nakamoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/bsv-2.0.10-MIT.txt b/apps/lch-reference/LICENSES/bsv-2.0.10-MIT.txt new file mode 100644 index 000000000..ec9e29dbd --- /dev/null +++ b/apps/lch-reference/LICENSES/bsv-2.0.10-MIT.txt @@ -0,0 +1,37 @@ +Copyright (c) 2016-2020 Yours Inc. + +Copyright (c) 2013-2017 BitPay, Inc. + +Copyright (c) 2014-2016 Ryan X. Charles + +Parts of this software are based on Bitcoin Core +Copyright (c) 2009-2016 The Bitcoin Core developers + +Parts of this software were developed by reddit +Copyright (c) 2014 reddit, Inc. + +Parts of this software are based on BitcoinJS +Copyright (c) 2011-2014 Bitcoinjs-lib contributors + +Parts of this software are based on BitcoinJ +Copyright (c) 2011 Google Inc. + +Copyright (c) 2009 Satoshi Nakamoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/closure-library-Apache-2.0.txt b/apps/lch-reference/LICENSES/closure-library-Apache-2.0.txt new file mode 100644 index 000000000..137069b82 --- /dev/null +++ b/apps/lch-reference/LICENSES/closure-library-Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/apps/lch-reference/LICENSES/elliptic-MIT.txt b/apps/lch-reference/LICENSES/elliptic-MIT.txt new file mode 100644 index 000000000..46a47c90a --- /dev/null +++ b/apps/lch-reference/LICENSES/elliptic-MIT.txt @@ -0,0 +1,22 @@ +This software is licensed under the MIT License. + +Copyright Fedor Indutny, 2014. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/hash.js-MIT.txt b/apps/lch-reference/LICENSES/hash.js-MIT.txt new file mode 100644 index 000000000..46a47c90a --- /dev/null +++ b/apps/lch-reference/LICENSES/hash.js-MIT.txt @@ -0,0 +1,22 @@ +This software is licensed under the MIT License. + +Copyright Fedor Indutny, 2014. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/hmac-drbg-MIT.txt b/apps/lch-reference/LICENSES/hmac-drbg-MIT.txt new file mode 100644 index 000000000..8108aa684 --- /dev/null +++ b/apps/lch-reference/LICENSES/hmac-drbg-MIT.txt @@ -0,0 +1,22 @@ +This software is licensed under the MIT License. + +Copyright Fedor Indutny, 2017. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/minimalistic-crypto-utils-MIT.txt b/apps/lch-reference/LICENSES/minimalistic-crypto-utils-MIT.txt new file mode 100644 index 000000000..8108aa684 --- /dev/null +++ b/apps/lch-reference/LICENSES/minimalistic-crypto-utils-MIT.txt @@ -0,0 +1,22 @@ +This software is licensed under the MIT License. + +Copyright Fedor Indutny, 2017. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/lch-reference/LICENSES/noble-hashes-MIT.txt b/apps/lch-reference/LICENSES/noble-hashes-MIT.txt new file mode 100644 index 000000000..9297a046d --- /dev/null +++ b/apps/lch-reference/LICENSES/noble-hashes-MIT.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Paul Miller (https://paulmillr.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/apps/lch-reference/LICENSES/python-mnemonic-MIT.txt b/apps/lch-reference/LICENSES/python-mnemonic-MIT.txt new file mode 100644 index 000000000..b13574464 --- /dev/null +++ b/apps/lch-reference/LICENSES/python-mnemonic-MIT.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013-2016 Pavol Rusnak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/lch-reference/LICENSES/sjcl-BSD-2-Clause.txt b/apps/lch-reference/LICENSES/sjcl-BSD-2-Clause.txt new file mode 100644 index 000000000..983d342e1 --- /dev/null +++ b/apps/lch-reference/LICENSES/sjcl-BSD-2-Clause.txt @@ -0,0 +1,58 @@ +SJCL is open. You can use, modify and redistribute it under a BSD +license or under the GNU GPL, version 2.0. + +--------------------------------------------------------------------- + +http://opensource.org/licenses/BSD-2-Clause + +Copyright (c) 2009-2015, Emily Stark, Mike Hamburg and Dan Boneh at +Stanford University. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------------------- + +http://opensource.org/licenses/GPL-2.0 + +The Stanford Javascript Crypto Library (hosted here on GitHub) is a +project by the Stanford Computer Security Lab to build a secure, +powerful, fast, small, easy-to-use, cross-browser library for +cryptography in Javascript. + +Copyright (c) 2009-2015, Emily Stark, Mike Hamburg and Dan Boneh at +Stanford University. + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 2 of the License, or (at your +option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +59 Temple Place, Suite 330, Boston, MA 02111-1307 USA \ No newline at end of file diff --git a/apps/lch-reference/README.md b/apps/lch-reference/README.md new file mode 100644 index 000000000..edf4bb548 --- /dev/null +++ b/apps/lch-reference/README.md @@ -0,0 +1,33 @@ +# BRC-170 LCH reference workbench + +This open-source reference application exercises `@bsv/lch` and published [BRC-170](https://bsv.brc.dev/apps/0170) as a complete creator-to-player path. + +The creator wizard encrypts a local asset into authenticated segments, stores detached ciphertext through a `ContentSink`, signs an inline Offer, and declares a two-wallet compensation split. The player performs a non-spending preflight, obtains a signed Quote, Payment Demands, short-lived Payment Readiness leases, and any Payee-authorized destinations, then stops at an explicit confirmation boundary. Confirmation refreshes readiness and asks a BRC-100 `WalletInterface` to create one BRC-105/BRC-29 transaction. + +The two Payees intentionally exercise different settlement profiles. The composition controller uses `receipt-complete-v1`: its wallet must internalize the output and sign a Receipt. The recording controller uses `authorized-output-v1`: before payment it signs the exact BRC-29 output, accepted-transaction provider, and durable Delivery route. The default edge-case toggle takes that controller offline immediately after refreshed signed readiness. The issuer releases keys only after independently verifying the exact output, signed processor acceptance, and retention of the buyer-signed Delivery through recovery. The workbench can then bring the controller online, authenticate retrieval, internalize the same transaction once, and display the late Receipt. A provider outage or a strict offline Payee leaves one visible pending transaction; neither path creates a replacement payment. + +The default browser workbench uses deterministic fixture wallets. They execute the same wallet methods and cryptographic derivations as the connected flow while constructing an input-free Atomic BEEF fixture. The collapsed server still routes issuer, recording-controller, composition-controller, evidence-provider, Delivery-store, and retrieval messages through distinct endpoints and verifies each signed role. Tests cover offline and late Payee recovery, provider outage and retry, strict-profile refusal, wrong output, insufficient evidence and retention, duplicate recovery, and conflicting accepted transactions. The Node reference server accepts an operator-provided wallet module, where each returned `WalletInterface` can be backed by a real BRC-100 wallet service. See [DEPLOYMENT.md](./DEPLOYMENT.md). + +The profile runner covers all six initial usage profiles plus the authorized-output settlement profile. Its deterministic PCM fixture renders repeated placements, half- and double-speed time warps, reversal, and distortion. Every placement has a distinct C2PA ingredient binding under `whole-placement-v1`. The edit description is non-critical application metadata; ordinary editorial transforms do not change the License permission model or settlement semantics. A future mapping profile is needed only when a resolver needs deterministic selective mapping from a derivative part back to a source part. + +## Run + +```sh +pnpm --filter lch-reference-app dev +``` + +Build and run the HTTP reference server: + +```sh +pnpm --filter lch-reference-app build +PORT=4173 LCH_PUBLIC_BASE_URL=http://127.0.0.1:4173 pnpm --filter lch-reference-app serve +``` + +The browser and server bundles incorporate `@bsv/sdk` and `@bsv/lch`. The new profile adds no dependency. The build copies the scoped `THIRD_PARTY_NOTICES.md` and exact `LICENSES/` archive into `dist/licenses/`; those files must remain alongside deployed copies. + +Use [DEPLOYMENT.md](./DEPLOYMENT.md) to replace every fixture and in-memory +boundary, connect production wallets and content storage, separate financial +roles, add persistence and recovery, and validate a rollout. The broader +[production CHIRP and LCH guide](../../docs/guides/chirp-lch-production.md) +explains when to combine the protocols, provides copyable consumer flows, and +includes failure, security, observability, and agent checklists. diff --git a/apps/lch-reference/THIRD_PARTY_NOTICES.md b/apps/lch-reference/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..75cba825d --- /dev/null +++ b/apps/lch-reference/THIRD_PARTY_NOTICES.md @@ -0,0 +1,266 @@ + + +# Third-Party Notices + +The Open BSV License Version 6 in `LICENSE.txt` applies to current TS Stack +first-party contributions. It does not replace, narrow, or relicense historical +or third-party material identified below. Each identified portion remains available +under its stated terms. + +Distributors must keep this file and the referenced `LICENSES/` files with source, +npm tarballs, browser bundles, WebAssembly artifacts, and container images that +contain the corresponding material. Ordinary dependency licenses remain with those +dependencies and are additionally inventoried in release SBOMs. + +Registry: `governance/third-party-materials.json` + +## Release clearance status + +The notices below reduce attribution risk but do not create rights. A release is +blocked while any item marked `required` remains unresolved. + +- **aes-gcm-js-exact-notice — cleared:** The exact npm publication and upstream history identify aes-gcm.js@1.0.0, Taner Mansur, and an express ISC grant; the derived notice and immutable evidence are retained. + Accepted evidence: governance/license-evidence/provenance.json record aes-gcm-js, the registry-pinned npm tarball, and LICENSES/aes-gcm.js-ISC.txt. +- **2026-stack-license-uniformization-authority — cleared:** The uniformization changed 107 total paths, including 55 license or policy texts; all 28 preexisting license/policy files are inventoried and every prior grant remains scoped to its snapshot code, so no blanket retroactive relicensing authority is relied upon. + Accepted evidence: governance/license-continuity.json, governance/license-evidence/pre-uniformization-root-policy.md, and the nine hash-pinned historical Open BSV texts. + +## aes-gcm.js (1.0.0) + +- License: `ISC` +- Use in this stack: direct TypeScript translation, subsequently modified +- Upstream: https://registry.npmjs.org/aes-gcm.js/-/aes-gcm.js-1.0.0.tgz +- License text: [aes-gcm.js-ISC.txt](./LICENSES/aes-gcm.js-ISC.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/AESGCM.ts` + +Copyright (c) 2019 Taner Mansur + +## BIP 32 specification test vectors (bitcoin/bips master snapshot) + +- License: `BSD-2-Clause` +- Use in this stack: source-only specification test vectors +- Upstream: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki +- License text: [BIP-0032-BSD-2-Clause.txt](./LICENSES/BIP-0032-BSD-2-Clause.txt) +- Incorporated paths: + - `packages/sdk/src/compat/__tests/HD.test.ts` + +Author: Pieter Wuille + +## BIP 39 specification and English word list (bitcoin/bips master snapshot) + +- License: `MIT` +- Use in this stack: specification implementation and byte-identical English word list +- Upstream: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki +- License text: [BIP-0039-MIT.txt](./LICENSES/BIP-0039-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/compat/Mnemonic.ts` + - `packages/sdk/src/compat/bip-39-wordlist-en.ts` + +Authors: Marek Palatinus, Pavol Rusnak, Aaron Voisine, and Sean Bowe + +## bitcoin-sv node test material (90a1a00e62b93c095b9ead39bbbd922873e1e6a9 and 172c8fa38cce30cf4df0327b33c7418ea6289de8) + +- License: `LicenseRef-Open-BSV-License-5 AND MIT` +- Use in this stack: source-only fixtures and adapted tests +- Upstream: https://github.com/bitcoin-sv/bitcoin-sv +- License text: [bitcoin-sv-879fc8b4.txt](./LICENSES/bitcoin-sv-879fc8b4.txt) +- Incorporated paths: + - `packages/sdk/src/script/__tests/ChronicleOpcodes.test.ts` + - `packages/sdk/src/script/__tests/fixtures/bitcoin-sv` + +Copyright holders listed in the accompanying bitcoin-sv license + +## bn.js (5.2.1 lineage) + +- License: `MIT` +- Use in this stack: modified and translated source +- Upstream: https://github.com/indutny/bn.js/tree/v5.2.1 +- License text: [bn.js-MIT.txt](./LICENSES/bn.js-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/BigNumber.ts` + - `packages/sdk/src/primitives/K256.ts` + - `packages/sdk/src/primitives/Mersenne.ts` + - `packages/sdk/src/primitives/MontgomoryMethod.ts` + - `packages/sdk/src/primitives/ReductionContext.ts` + +Copyright (c) 2015 Fedor Indutny + +## brorand (1.1.0 lineage) + +- License: `MIT` +- Use in this stack: modified source lineage +- Upstream: https://github.com/indutny/brorand +- License text: [brorand-MIT.txt](./LICENSES/brorand-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/Random.ts` + +Copyright Fedor Indutny, 2014 + +## bsv (1.5.6) + +- License: `MIT` +- Use in this stack: source-only sighash test vectors +- Upstream: https://www.npmjs.com/package/bsv/v/1.5.6 +- License text: [bsv-1.5.6-MIT.txt](./LICENSES/bsv-1.5.6-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/__tests/sighash.vectors.ts` + +Copyright (c) 2018-2019 Yours Inc. +Copyright (c) 2013-2017 BitPay, Inc. and the additional upstream authors listed in the license text + +## bsv (2.0.10 at feab6d528c4013f6332b33169e771a95bc201285) + +- License: `MIT` +- Use in this stack: modified compatibility, primitive, script, and test source lineage +- Upstream: https://github.com/moneybutton/bsv/tree/feab6d528c4013f6332b33169e771a95bc201285 +- License text: [bsv-2.0.10-MIT.txt](./LICENSES/bsv-2.0.10-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/compat/ECIES.ts` + - `packages/sdk/src/compat/HD.ts` + - `packages/sdk/src/compat/Mnemonic.ts` + - `packages/sdk/src/compat/__tests/ECIES.test.ts` + - `packages/sdk/src/compat/__tests/HD.test.ts` + - `packages/sdk/src/compat/__tests/Mnemonic.test.ts` + - `packages/sdk/src/primitives/ReaderUint8Array.ts` + - `packages/sdk/src/primitives/Signature.ts` + - `packages/sdk/src/primitives/utils.ts` + - `packages/sdk/src/primitives/__tests/Reader.test.ts` + - `packages/sdk/src/primitives/__tests/ReaderUint8Array.test.ts` + - `packages/sdk/src/primitives/__tests/Writer.test.ts` + - `packages/sdk/src/primitives/__tests/WriterUint8Array.test.ts` + - `packages/sdk/src/script/OP.ts` + - `packages/sdk/src/script/__tests/Script.test.ts` + +Copyright (c) 2016-2020 Yours Inc. +Copyright (c) 2013-2017 BitPay, Inc. and the additional upstream authors listed in the license text + +## Google Closure Library (8598d87242af59aac233270742c8984e2b2bdbe0) + +- License: `Apache-2.0` +- Use in this stack: modified UTF-8 encoding helper +- Upstream: https://github.com/google/closure-library/blob/8598d87242af59aac233270742c8984e2b2bdbe0/closure/goog/crypt/crypt.js +- License text: [closure-library-Apache-2.0.txt](./LICENSES/closure-library-Apache-2.0.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/Hash.ts` + +Copyright 2008 The Closure Library Authors + +## elliptic (6.x lineage) + +- License: `MIT` +- Use in this stack: modified and translated source +- Upstream: https://github.com/indutny/elliptic +- License text: [elliptic-MIT.txt](./LICENSES/elliptic-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/BasePoint.ts` + - `packages/sdk/src/primitives/Curve.ts` + - `packages/sdk/src/primitives/ECDSA.ts` + - `packages/sdk/src/primitives/JacobianPoint.ts` + - `packages/sdk/src/primitives/Point.ts` + - `packages/sdk/src/primitives/Polynomial.ts` + - `packages/sdk/src/primitives/Signature.ts` + +Copyright Fedor Indutny, 2014 + +## hash.js (1.x lineage) + +- License: `MIT` +- Use in this stack: modified and translated source +- Upstream: https://github.com/indutny/hash.js +- License text: [hash.js-MIT.txt](./LICENSES/hash.js-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/Hash.ts` + +Copyright Fedor Indutny, 2014 + +## hmac-drbg (1.0.1 lineage) + +- License: `MIT` +- Use in this stack: modified and translated source +- Upstream: https://github.com/indutny/hmac-drbg +- License text: [hmac-drbg-MIT.txt](./LICENSES/hmac-drbg-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/DRBG.ts` + +Copyright Fedor Indutny, 2017 + +## minimalistic-crypto-utils (1.0.1 lineage) + +- License: `MIT` +- Use in this stack: modified and translated source +- Upstream: https://github.com/indutny/minimalistic-crypto-utils +- License text: [minimalistic-crypto-utils-MIT.txt](./LICENSES/minimalistic-crypto-utils-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/utils.ts` + +Copyright Fedor Indutny, 2017 + +## @noble/hashes (1.8.0) + +- License: `MIT` +- Use in this stack: inlined and modified PBKDF2, HMAC, SHA-256 and SHA-512 helpers +- Upstream: https://github.com/paulmillr/noble-hashes/tree/1.8.0 +- License text: [noble-hashes-MIT.txt](./LICENSES/noble-hashes-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/primitives/Hash.ts` + +Copyright (c) 2022 Paul Miller (paulmillr.com) + +## TS Stack pre-uniformization Open BSV License version 4 source (snapshot d215223af67b2b08ef628e8e07f5cff8b60ec9b3) + +- License: `LicenseRef-Open-BSV-License-4` +- Use in this stack: license continuity for source present before the 2026 license-file uniformization +- Upstream: https://github.com/bsv-blockchain/ts-stack/tree/d215223af67b2b08ef628e8e07f5cff8b60ec9b3 +- License text: [Open-BSV-License-4-standard-pre-2026.txt](./LICENSES/Open-BSV-License-4-standard-pre-2026.txt) +- Incorporated paths: + - `infra/uhrp-server-basic` + - `infra/uhrp-server-cloud-bucket` + - `infra/uhrp-server-cloud-bucket/notifier` + - `infra/wab` + - `infra/wallet-infra` + - `packages/helpers/ts-templates` + - `packages/messaging/authsocket-client` + - `packages/messaging/authsocket` + - `packages/messaging/message-box-client` + - `packages/middleware/auth-express-middleware` + - `packages/middleware/payment-express-middleware` + - `packages/overlays/gasp-core` + - `packages/overlays/overlay-discovery-services` + - `packages/overlays/overlay-express` + - `packages/overlays/overlay` + - `packages/sdk` + +Copyright (c) 2023 BSV Blockchain Association (Bitcoin Association) + +## python-mnemonic BIP 39 test vectors (master lineage) + +- License: `MIT` +- Use in this stack: source-only reformatted test vectors +- Upstream: https://github.com/trezor/python-mnemonic/blob/master/vectors.json +- License text: [python-mnemonic-MIT.txt](./LICENSES/python-mnemonic-MIT.txt) +- Incorporated paths: + - `packages/sdk/src/compat/__tests/Mnemonic.vectors.ts` + +Copyright (c) 2013-2016 Pavol Rusnak + +## Stanford Javascript Crypto Library (1.x lineage) + +- License: `BSD-2-Clause` +- Use in this stack: modified and translated AES implementation +- Upstream: https://github.com/bitwiseshiftleft/sjcl/blob/master/core/aes.js +- License text: [sjcl-BSD-2-Clause.txt](./LICENSES/sjcl-BSD-2-Clause.txt) +- Incorporated paths: + - `packages/sdk/src/compat/ECIES.ts` + +Copyright (c) 2009-2015, Emily Stark, Mike Hamburg and Dan Boneh at Stanford University + +## Teranode consensus test material (2355e57b80af962327930ea32568a1b322361542) + +- License: `LicenseRef-Open-BSV-License-6` +- Use in this stack: source-only fixtures +- Upstream: https://github.com/bsv-blockchain/teranode/tree/2355e57b80af962327930ea32568a1b322361542/test/consensus/testdata +- License text: [Teranode-Open-BSV-License-6.txt](./LICENSES/Teranode-Open-BSV-License-6.txt) +- Incorporated paths: + - `packages/sdk/src/script/__tests/fixtures/teranode` + +BSV Association diff --git a/apps/lch-reference/index.html b/apps/lch-reference/index.html new file mode 100644 index 000000000..efb4cc996 --- /dev/null +++ b/apps/lch-reference/index.html @@ -0,0 +1,16 @@ + + + + + + + BRC-170 LCH Reference Workbench + + +
+ + + diff --git a/apps/lch-reference/package.json b/apps/lch-reference/package.json new file mode 100644 index 000000000..19fc8eb21 --- /dev/null +++ b/apps/lch-reference/package.json @@ -0,0 +1,29 @@ +{ + "name": "lch-reference-app", + "version": "0.1.0", + "private": true, + "author": "BSV Association", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build && vite build --ssr src/nodeServer.ts --outDir dist-server && node scripts/copy-notices.mjs", + "serve": "node dist-server/nodeServer.js", + "typecheck": "tsc --noEmit", + "lint": "oxlint src test --deny-warnings", + "format:check": "pnpm --workspace-root exec prettier --check \"apps/lch-reference/**/*.{css,html,json,md,mjs,ts}\"", + "test": "vitest run" + }, + "dependencies": { + "@bsv/lch": "workspace:^", + "@bsv/sdk": "workspace:^" + }, + "devDependencies": { + "@typescript/native": "npm:typescript@7.0.2", + "@types/node": "^26.1.2", + "oxlint": "^1.76.0", + "typescript": "npm:@typescript/typescript6@6.0.2", + "vite": "^8.1.5", + "vitest": "^4.1.10" + }, + "license": "SEE LICENSE IN LICENSE.txt" +} diff --git a/apps/lch-reference/scripts/copy-notices.mjs b/apps/lch-reference/scripts/copy-notices.mjs new file mode 100644 index 000000000..6f02a316c --- /dev/null +++ b/apps/lch-reference/scripts/copy-notices.mjs @@ -0,0 +1,16 @@ +import { cp, mkdir } from 'node:fs/promises' + +await mkdir(new URL('../dist/licenses/', import.meta.url), { recursive: true }) +for (const name of ['LICENSE.txt', 'THIRD_PARTY_NOTICES.md']) { + await cp( + new URL(`../${name}`, import.meta.url), + new URL(`../dist/licenses/${name}`, import.meta.url) + ) +} +await cp( + new URL('../LICENSES/', import.meta.url), + new URL('../dist/licenses/LICENSES/', import.meta.url), + { + recursive: true + } +) diff --git a/apps/lch-reference/src/demo.ts b/apps/lch-reference/src/demo.ts new file mode 100644 index 000000000..213a3c885 --- /dev/null +++ b/apps/lch-reference/src/demo.ts @@ -0,0 +1,387 @@ +import { + LCHComposer, + LCH_MECHANISMS, + LCH_PROFILES, + MemoryLicenseStore, + decryptSegmented, + encryptSegmented, + keyPeriodsForSelection, + parsePinnedPolicy, + permits, + sha256, + supportsProfile, + timeWindowStatus, + toHex, + validateCompositionRecord, + validateKeyGrantsForSelection, + type CompositionRecord, + type LCHValue, + type SignedObject +} from '@bsv/lch' + +export type EditorialTransformKind = 'identity' | 'time-warp' | 'reverse' | 'distortion' + +export interface EditorialPlacement { + id: number + label: string + kind: EditorialTransformKind + rateNumerator?: number + rateDenominator?: number + distortionAmount?: number +} + +export interface ProfileCheck { + profile: string + status: 'pass' + observations: string[] +} + +export const EDITORIAL_CASES: ReadonlyArray> = [ + { label: 'unaltered', kind: 'identity' }, + { label: 'half speed', kind: 'time-warp', rateNumerator: 1, rateDenominator: 2 }, + { label: 'double speed', kind: 'time-warp', rateNumerator: 2, rateDenominator: 1 }, + { label: 'reversed', kind: 'reverse' }, + { label: 'distorted', kind: 'distortion', distortionAmount: 4 } +] + +export function createToneWav(durationSeconds = 2, frequency = 220): Uint8Array { + const sampleRate = 22_050 + const samples = new Int16Array(Math.floor(sampleRate * durationSeconds)) + for (let index = 0; index < samples.length; index += 1) { + const envelope = Math.min(1, index / 300) * Math.min(1, (samples.length - index) / 800) + const sample = Math.sin((2 * Math.PI * frequency * index) / sampleRate) * envelope + samples[index] = Math.round(sample * 0x5fff) + } + return encodePcm16MonoWav(samples, sampleRate) +} + +export function transformToneWav(source: Uint8Array, placement: EditorialPlacement): Uint8Array { + const { samples, sampleRate } = decodePcm16MonoWav(source) + const numerator = placement.rateNumerator ?? 1 + const denominator = placement.rateDenominator ?? 1 + if ( + !Number.isSafeInteger(numerator) || + !Number.isSafeInteger(denominator) || + numerator <= 0 || + denominator <= 0 + ) { + throw new TypeError('Playback-rate ratio must contain positive safe integers') + } + const rate = numerator / denominator + const output = new Int16Array(Math.max(1, Math.round(samples.length / rate))) + for (let index = 0; index < output.length; index += 1) { + const sourceOffset = Math.min(samples.length - 1, Math.floor(index * rate)) + const sourceIndex = + placement.kind === 'reverse' ? samples.length - sourceOffset - 1 : sourceOffset + let normalized = samples[sourceIndex] / 0x7fff + if (placement.kind === 'distortion') { + const amount = placement.distortionAmount ?? 4 + normalized = Math.tanh(normalized * amount) / Math.tanh(amount) + } + output[index] = Math.round(Math.max(-1, Math.min(1, normalized)) * 0x7fff) + } + return encodePcm16MonoWav(output, sampleRate) +} + +export async function buildEditorialComposition( + sourceAssetId: Uint8Array, + sourceLicenseId: Uint8Array, + placements: readonly EditorialPlacement[] +): Promise { + const composer = new LCHComposer( + await sha256(new TextEncoder().encode('reference-c2pa-manifest')) + ) + for (const placement of placements) { + const editMetadata: Record = { + kind: placement.kind, + label: placement.label, + ...(placement.rateNumerator === undefined + ? {} + : { + playbackRate: { + numerator: placement.rateNumerator, + denominator: placement.rateDenominator ?? 1 + } + }), + ...(placement.distortionAmount === undefined + ? {} + : { distortionAmount: placement.distortionAmount }) + } + composer.addWholePlacement({ + sourceAssetId, + sourceLicenseId, + c2paIngredient: { + url: `self#jumbf=/c2pa/reference/c2pa.assertions/c2pa.ingredient.v3/${placement.id}`, + alg: 'sha256', + hash: await sha256(new TextEncoder().encode(`placement:${placement.id}`)) + }, + relationship: 'componentOf', + sourceSelection: { type: 'all' }, + metadata: { + placementId: placement.id, + 'https://example.invalid/lch-reference/edit-v1': editMetadata + } + }) + } + return composer.build() +} + +export async function runCoreProfileChecks( + sourceAssetId: Uint8Array, + sourceLicenseId: Uint8Array +): Promise { + const mechanisms = { + payment: LCH_MECHANISMS.brc105Single, + keyDelivery: LCH_MECHANISMS.brc78Key, + encryption: LCH_MECHANISMS.encryption, + enforcement: 'https://bsv.brc.dev/apps/0170#conformingApplication' + } + const supported = Object.values(LCH_PROFILES).every(usageProfile => + supportsProfile({ usageProfile, ...mechanisms }) + ) + if (!supported) throw new Error('The reference capability set omitted a core profile') + if ( + supportsProfile({ + usageProfile: LCH_PROFILES.fixedRender, + ...mechanisms, + critical: ['https://example.invalid/unknown-critical-profile'] + }) + ) { + throw new Error('An unknown critical profile was accepted') + } + + const encrypted = await encryptSegmented(new TextEncoder().encode('0123456789abcdefWXYZ'), { + segmentSize: 4, + keyPeriodSegments: 1 + }) + const range = { type: 'segments' as const, ranges: [[1, 3] as const] } + const selectedPeriods = keyPeriodsForSelection(encrypted.descriptor, range) + const grants = selectedPeriods.map(period => ({ keyId: period.keyId })) + validateKeyGrantsForSelection(encrypted.descriptor, range, grants) + const selectedKeys = new Map( + selectedPeriods.map(period => [toHex(period.keyId), encrypted.keys.get(toHex(period.keyId))!]) + ) + const rangePlaintext = await decryptSegmented( + encrypted.ciphertext, + encrypted.descriptor, + selectedKeys, + range + ) + let missingKeyRejected = false + let extraKeyRejected = false + try { + validateKeyGrantsForSelection(encrypted.descriptor, range, grants.slice(1)) + } catch { + missingKeyRejected = true + } + const extraPeriod = encrypted.descriptor.keyPeriods.find( + period => !selectedPeriods.some(selected => toHex(selected.keyId) === toHex(period.keyId)) + ) + if (extraPeriod !== undefined) { + try { + validateKeyGrantsForSelection(encrypted.descriptor, range, [ + ...grants, + { keyId: extraPeriod.keyId } + ]) + } catch { + extraKeyRejected = true + } + } + if ( + new TextDecoder().decode(rangePlaintext) !== '456789ab' || + !missingKeyRejected || + !extraKeyRejected + ) { + throw new Error('Metered-range edge checks failed') + } + + const agreementBytes = new TextEncoder().encode( + JSON.stringify({ + '@context': ['http://www.w3.org/ns/odrl.jsonld'], + '@type': 'Agreement', + uid: 'lch:license:self', + profile: 'https://bsv.brc.dev/apps/0170#odrl-profile', + permission: [{ target: `lch:asset:sha256:${toHex(sourceAssetId)}`, action: 'read' }] + }) + ) + const agreement = await parsePinnedPolicy( + { + mediaType: 'application/ld+json', + digest: await sha256(agreementBytes), + inline: agreementBytes + }, + 'Agreement', + `lch:license:sha256:${toHex(sourceLicenseId)}` + ) + const target = `lch:asset:sha256:${toHex(sourceAssetId)}` + if (!permits(agreement, 'read', target)) throw new Error('Metered-event Agreement was not usable') + const entitlement: SignedObject = { body: { profile: LCH_PROFILES.meteredEvent }, signatures: [] } + const store = new MemoryLicenseStore() + await store.put({ + assetId: toHex(sourceAssetId), + offerId: toHex(sourceLicenseId), + license: entitlement, + storedAt: 1n + }) + const firstRead = await store.get(toHex(sourceAssetId), toHex(sourceLicenseId)) + const repeatedRead = await store.get(toHex(sourceAssetId), toHex(sourceLicenseId)) + if (firstRead?.license !== repeatedRead?.license) { + throw new Error('Stored entitlement was not reused for a repeated event') + } + + const rentalWindow = { notBefore: 100, notAfter: 200 } + const rentalStatuses = [99, 100, 199, 200].map(now => timeWindowStatus(rentalWindow, now)) + if (rentalStatuses.join(',') !== 'not-started,active,active,expired') { + throw new Error('Rental boundaries are not half-open') + } + + const editorialPlacements: EditorialPlacement[] = [ + { id: 1, ...EDITORIAL_CASES[0] }, + { id: 2, ...EDITORIAL_CASES[0] }, + ...EDITORIAL_CASES.slice(1).map((placement, index) => ({ id: index + 3, ...placement })) + ] + const composition = await buildEditorialComposition( + sourceAssetId, + sourceLicenseId, + editorialPlacements + ) + if ( + composition.ingredients.length !== editorialPlacements.length || + !composition.ingredients.every( + ingredient => + ingredient.mappingProfile === LCH_MECHANISMS.wholePlacement && + ingredient.derivedSelection.type === 'all' + ) + ) { + throw new Error('Editorial composition changed whole-placement semantics') + } + let duplicateBindingRejected = false + try { + validateCompositionRecord({ + ...composition, + ingredients: [composition.ingredients[0]!, composition.ingredients[0]!] + }) + } catch { + duplicateBindingRejected = true + } + if (!duplicateBindingRejected) throw new Error('A repeated C2PA assertion binding was accepted') + + const training = new LCHComposer(await sha256(new TextEncoder().encode('training-claim'))) + .addWholePlacement({ + sourceAssetId, + sourceLicenseId, + c2paIngredient: { + url: 'self#jumbf=/c2pa/reference/training-input', + alg: 'sha256', + hash: await sha256(new TextEncoder().encode('training-input')) + }, + relationship: 'inputTo', + sourceSelection: { type: 'all' } + }) + .build() + if (training.ingredients[0]?.relationship !== 'inputTo') { + throw new Error('Claimed training source was not recorded as inputTo') + } + + return [ + { + profile: LCH_PROFILES.fixedRender, + status: 'pass', + observations: [ + 'profile/mechanism set supported', + 'opening remains non-spending', + 'unknown critical semantics rejected' + ] + }, + { + profile: LCH_PROFILES.meteredRange, + status: 'pass', + observations: [ + 'two selected records authenticated', + 'missing and extra key periods fail closed' + ] + }, + { + profile: LCH_PROFILES.meteredEvent, + status: 'pass', + observations: ['existing exact entitlement reused', 'repeat read created no purchase'] + }, + { + profile: LCH_PROFILES.rental, + status: 'pass', + observations: ['notBefore inclusive', 'notAfter exclusive'] + }, + { + profile: LCH_PROFILES.composition, + status: 'pass', + observations: [ + 'repeats bind distinct C2PA assertions', + 'duplicate assertion binding rejected', + 'editorial transforms keep whole placement' + ] + }, + { + profile: LCH_PROFILES.training, + status: 'pass', + observations: [ + 'training alone needs no derivative claim', + 'claimed individual source uses inputTo' + ] + } + ] +} + +export function randomBytes(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) +} + +function decodePcm16MonoWav(bytes: Uint8Array): { samples: Int16Array; sampleRate: number } { + if ( + bytes.length < 44 || + new TextDecoder().decode(bytes.slice(0, 4)) !== 'RIFF' || + new TextDecoder().decode(bytes.slice(8, 12)) !== 'WAVE' + ) { + throw new TypeError('Reference transforms require a PCM WAV fixture') + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + if ( + view.getUint16(20, true) !== 1 || + view.getUint16(22, true) !== 1 || + view.getUint16(34, true) !== 16 + ) { + throw new TypeError('Reference transforms require mono 16-bit PCM') + } + const sampleBytes = view.getUint32(40, true) + if (44 + sampleBytes !== bytes.length || sampleBytes % 2 !== 0) { + throw new TypeError('Reference WAV has an invalid data chunk') + } + const samples = new Int16Array(sampleBytes / 2) + for (let index = 0; index < samples.length; index += 1) { + samples[index] = view.getInt16(44 + index * 2, true) + } + return { samples, sampleRate: view.getUint32(24, true) } +} + +function encodePcm16MonoWav(samples: Int16Array, sampleRate: number): Uint8Array { + const bytes = new Uint8Array(44 + samples.length * 2) + const view = new DataView(bytes.buffer) + const text = (offset: number, value: string): void => { + for (let index = 0; index < value.length; index += 1) + bytes[offset + index] = value.codePointAt(index) ?? 0 + } + text(0, 'RIFF') + view.setUint32(4, bytes.length - 8, true) + text(8, 'WAVEfmt ') + view.setUint32(16, 16, true) + view.setUint16(20, 1, true) + view.setUint16(22, 1, true) + view.setUint32(24, sampleRate, true) + view.setUint32(28, sampleRate * 2, true) + view.setUint16(32, 2, true) + view.setUint16(34, 16, true) + text(36, 'data') + view.setUint32(40, samples.length * 2, true) + for (let index = 0; index < samples.length; index += 1) + view.setInt16(44 + index * 2, samples[index], true) + return bytes +} diff --git a/apps/lch-reference/src/fixtureWallet.ts b/apps/lch-reference/src/fixtureWallet.ts new file mode 100644 index 000000000..9f82cd71f --- /dev/null +++ b/apps/lch-reference/src/fixtureWallet.ts @@ -0,0 +1,68 @@ +import { + LockingScript, + PrivateKey, + ProtoWallet, + Transaction, + type CreateActionArgs, + type CreateActionResult, + type InternalizeActionArgs, + type InternalizeActionResult, + type WalletInterface +} from '@bsv/sdk' + +export interface FixtureWallet extends WalletInterface { + readonly createdActions: number + readonly receivedSatoshis: number + readonly internalizedActions: readonly InternalizeActionArgs[] +} + +/** + * Adds only the transaction methods deliberately absent from ProtoWallet. + * It is an executable fixture for the reference workbench, not a network wallet: + * production mode supplies the same WalletInterface from a BRC-100 wallet. + */ +export function createFixtureWallet(privateKey: number): FixtureWallet { + const proto = new ProtoWallet(new PrivateKey(privateKey)) + const internalizedActions: InternalizeActionArgs[] = [] + let createdActions = 0 + let receivedSatoshis = 0 + + const createAction = async (args: CreateActionArgs): Promise => { + createdActions += 1 + const outputs = (args.outputs ?? []) + .map(output => ({ + satoshis: output.satoshis, + lockingScript: LockingScript.fromHex(output.lockingScript) + })) + .reverse() + const transaction = new Transaction(args.version ?? 1, [], outputs, args.lockTime ?? 0) + return { + txid: transaction.id('hex'), + tx: transaction.toAtomicBEEF(true) + } + } + + const internalizeAction = async ( + args: InternalizeActionArgs + ): Promise => { + const transaction = Transaction.fromAtomicBEEF(args.tx) + for (const output of args.outputs) { + const transactionOutput = transaction.outputs[output.outputIndex] + if (transactionOutput?.satoshis !== undefined) receivedSatoshis += transactionOutput.satoshis + } + internalizedActions.push(args) + return { accepted: true } + } + + return new Proxy(proto as unknown as FixtureWallet, { + get(target, property, receiver) { + if (property === 'createAction') return createAction + if (property === 'internalizeAction') return internalizeAction + if (property === 'createdActions') return createdActions + if (property === 'receivedSatoshis') return receivedSatoshis + if (property === 'internalizedActions') return internalizedActions + const value = Reflect.get(target, property, receiver) as unknown + return typeof value === 'function' ? value.bind(target) : value + } + }) +} diff --git a/apps/lch-reference/src/main.ts b/apps/lch-reference/src/main.ts new file mode 100644 index 000000000..cecbe9c04 --- /dev/null +++ b/apps/lch-reference/src/main.ts @@ -0,0 +1,546 @@ +import type { WalletInterface } from '@bsv/sdk' +import { + LCH_PROFILES, + LCH_SETTLEMENT_PROFILES, + LCHReader, + toHex, + type SegmentedEncryptionDescriptor +} from '@bsv/lch' +import { + EDITORIAL_CASES, + buildEditorialComposition, + createToneWav, + runCoreProfileChecks, + transformToneWav, + type EditorialPlacement +} from './demo.js' +import { createFixtureWallet } from './fixtureWallet.js' +import { + ReferenceLCHClient, + type ReferenceAcquisitionPlan, + type ReferenceAcquisitionResult +} from './referenceClient.js' +import { ReferenceLCHServer } from './referenceServer.js' +import './style.css' + +interface DemoAsset { + name: string + mediaType: string + plaintext: Uint8Array + assetId: Uint8Array + offerId: Uint8Array + lchBytes: Uint8Array +} + +const issuerWallet = createFixtureWallet(11) +const recordingWallet = createFixtureWallet(12) +const compositionWallet = createFixtureWallet(13) +const fixtureBuyerWallet = createFixtureWallet(14) +const referenceOrigin = 'https://lch-reference.invalid' +let buyerWallet: WalletInterface = fixtureBuyerWallet +let server = await createServer(7, 5) +let client = createClient(server, buyerWallet) +let current: DemoAsset | undefined +let pendingPlan: ReferenceAcquisitionPlan | undefined +let currentLicenseId: Uint8Array | undefined +let placements: EditorialPlacement[] = [] +let playerUrl: string | undefined +let transformUrl: string | undefined + +document.querySelector('#app')!.innerHTML = ` +
+
LCH reference workbenchDraft BRC-170 · neutral open-source test application
+ BRC-100 fixture wallets +
fixture wallets ready
+
+
+
+

REFERENCE IMPLEMENTATION

+

Create, pay for, play, and compose an LCH asset.

+

This open reference workbench follows one asset through the draft BRC-170 roles. Every signed object, payment split, recovered key, profile boundary, and composition binding remains inspectable.

+
Transaction boundary. Preflight resolves and verifies the ciphertext, Offer, Quote, and every Payment Demand. Only the separately labelled confirmation asks the selected BRC-100 wallet to create the transaction.
+
+ +
+
Creatorplaintext + rights interests
+
Content hostverified ciphertext locator
+
Issuer serviceLCH, Offer, Quote, License
+
Buyer walletone BRC-100 action
+
Payee endpointsreadiness, authorization, Receipt
+
Evidence providersigned processor acceptance
+
Delivery providerretention + Payee retrieval
+
PlayerBRC-78 keys + authenticated media
+
+ +
+
+

1 · CREATOR WIZARD

Publish a protected asset

+

Choose media, declare the two reference rights interests, and set the exact split that becomes signed Payment Demands.

+
+ + +
+ +
+
No protected asset
+
+ +
+
encrypted media
+
+

2 · PLAYER + WALLET

Verified acquisition

+

Publish a fixture to enable preflight.

+
SIGNED QUOTE12 satoshis7 + 5 · two signed delivery routes · one transaction
+ + +

The composition controller uses receipt-complete-v1. The recording controller opts into authorized-output-v1: its exact BRC-29 destination, evidence provider, and durable Delivery route are signed before payment. Silence alone never releases a License.

+ +
No wallet transaction or License
+
+
+ +
+

3 · PROFILES

Initial profile checks

These executable scenarios pin interoperability boundaries across the initial profiles.

+
+ ${Object.entries(LCH_PROFILES) + .map( + ([name, iri]) => + `
pending

${profileLabel(name)}

${fragment(iri)}
  • awaiting licensed fixture
` + ) + .join('')} +
pending

Authorized output settlement

authorized-output-v1
  • awaiting acquisition case
+
+
No checks have run.
+
+ +
+

4 · COMPOSE

Whole-placement edit cases

Repeats, reversal, time-warping, and distortion are demonstrated as real edits. Their non-critical timeline metadata does not create new permission or settlement semantics.

+
+ ${EDITORIAL_CASES.map( + (item, index) => + `` + ).join('')} +
+
derived timeline
no placements
+
+ +

Each placement binds a distinct C2PA ingredient assertion. The app-specific edit description is immutable evidence but is ignored by the whole-placement obligation resolver.

+
Awaiting a licensed source.
+
+
+
BRC-170 draft · @bsv/lch 0.1.0Open reference code · exact fixtures and conformance cases
+` + +const fileInput = document.querySelector('#media-file')! +const toneButton = document.querySelector('#tone')! +const acquireButton = document.querySelector('#acquire-button')! +const checksButton = document.querySelector('#run-checks')! +const manifestButton = document.querySelector('#manifest')! +const previewButton = document.querySelector('#preview-edit')! +const recoveryButton = document.querySelector('#recover-recording')! +const offlineRecording = document.querySelector('#offline-recording')! +const recordingSettlement = document.querySelector('#recording-settlement')! +const editButtons = [...document.querySelectorAll('[data-edit]')] + +fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0] + if (file !== undefined) { + void file + .arrayBuffer() + .then(buffer => + publish(new Uint8Array(buffer), file.type || 'application/octet-stream', file.name) + ) + } +}) +toneButton.addEventListener( + 'click', + () => void publish(createToneWav(), 'audio/wav', 'lch-reference-loop.wav') +) +acquireButton.addEventListener('click', () => void acquire()) +checksButton.addEventListener('click', () => void runChecks()) +manifestButton.addEventListener('click', () => void buildComposition()) +previewButton.addEventListener('click', previewLastEdit) +recoveryButton.addEventListener('click', () => void recoverRecordingPayment()) +recordingSettlement.addEventListener('change', () => { + const authorized = recordingSettlement.value === LCH_SETTLEMENT_PROFILES.authorizedOutput + offlineRecording.disabled = !authorized + if (!authorized) offlineRecording.checked = false +}) +editButtons.forEach(button => { + button.addEventListener('click', () => { + const index = Number(button.dataset.edit) + const template = EDITORIAL_CASES[index] + if (template !== undefined) place(template) + }) +}) + +async function publish(bytes: Uint8Array, mediaType: string, name: string): Promise { + status('protecting and signing') + const recordingPrice = exactPrice('recording-price') + const compositionPrice = exactPrice('composition-price') + const recordingSettlementProfile = recordingSettlement.value + server = await createServer(recordingPrice, compositionPrice, recordingSettlementProfile) + client = createClient(server, buyerWallet) + const published = await server.publish({ bytes, mediaType, name }) + const inspected = await new LCHReader(server.content).inspect(published.lch) + current = { + name, + mediaType, + plaintext: bytes, + assetId: published.assetId, + offerId: published.offerId, + lchBytes: published.lch + } + pendingPlan = undefined + currentLicenseId = undefined + placements = [] + acquireButton.disabled = false + acquireButton.textContent = 'Preflight & quote' + checksButton.disabled = true + editButtons.forEach(button => (button.disabled = true)) + manifestButton.disabled = true + previewButton.disabled = true + recoveryButton.disabled = true + document.querySelector('#clips')!.innerHTML = 'no placements' + document.querySelector('#composition-output')!.textContent = 'Awaiting a licensed source.' + document.querySelector('#settlement-receipt')!.innerHTML = + '
No wallet transaction or License
' + document.querySelector('#profile-output')!.textContent = 'No checks have run.' + resetProfileCards() + document.querySelector('#asset-title')!.textContent = name + document.querySelector('#asset-copy')!.textContent = + `${mediaType} · ${bytes.length.toLocaleString()} plaintext bytes · ${published.lch.length.toLocaleString()} detached-header bytes` + document.querySelector('#quote-total')!.textContent = + `${recordingPrice + compositionPrice} satoshis` + document.querySelector('#quote-split')!.textContent = + `${recordingPrice} + ${compositionPrice} · two signed delivery routes · one transaction` + document.querySelector('#publish-receipt')!.innerHTML = receiptRows([ + ['ASSET ID', short(toHex(published.assetId))], + ['OFFER ID', short(toHex(published.offerId))], + [ + 'KEY PERIODS', + String( + (inspected.representation.encryption as unknown as SegmentedEncryptionDescriptor).keyPeriods + .length + ) + ], + ['CONTENT ADAPTER', 'detached + digest verified'], + ['PAYMENT SPLIT', `${recordingPrice} / ${compositionPrice} sat`], + ['RECORDING SETTLEMENT', fragment(recordingSettlementProfile)], + ['PAYEE ROUTES', server.payeeEndpoints.map(item => endpointLabel(item.endpoint)).join(' + ')], + ['USAGE PROFILE', 'fixed-render-v1'] + ]) + document.querySelector('#media-stage')!.innerHTML = + '
ciphertext verified · plaintext locked
' + status('header and offer ready') +} + +async function acquire(): Promise { + if (current === undefined) return + acquireButton.disabled = true + try { + if (pendingPlan === undefined) { + await prepareAcquisition(current) + return + } + await completeAcquisition(current, pendingPlan) + } catch (error) { + renderAcquisitionError(error) + } +} + +async function prepareAcquisition(asset: DemoAsset): Promise { + acquireButton.textContent = 'Validating Offer, Quote & Demands…' + pendingPlan = await client.prepare(asset.lchBytes) + acquireButton.textContent = `Confirm ${pendingPlan.totalSatoshis} satoshis in wallet` + acquireButton.disabled = false + const providers = pendingPlan.authorizations.map( + item => + `${endpointLabel(String(item.body.evidenceEndpoint))} + ${endpointLabel(String(item.body.deliveryEndpoint))}` + ) + document.querySelector('#settlement-receipt')!.innerHTML = receiptRows([ + ['SIGNED READINESS', `${pendingPlan.readiness.length} / ${pendingPlan.demands.length}`], + [ + 'SIGNED DESTINATIONS', + `${pendingPlan.authorizations.length} authorized-output / ${pendingPlan.demands.length} total` + ], + ['FALLBACK PROVIDERS', providers.length === 0 ? 'none selected' : providers.join(', ')], + ['TRANSACTION', 'not created'], + ['NEXT STEP', 'explicit wallet confirmation'] + ]) + status('signed readiness passed · no transaction created · confirmation required') +} + +async function completeAcquisition( + asset: DemoAsset, + plan: ReferenceAcquisitionPlan +): Promise { + acquireButton.textContent = 'Creating wallet transaction…' + if (offlineRecording.checked) server.setPayeeOfflineAfterNextReadiness('recording controller') + const result = await client.acquire(plan) + pendingPlan = undefined + currentLicenseId = result.licenseId + renderLicensedAsset(asset, result) + await runChecks() +} + +function renderLicensedAsset(asset: DemoAsset, result: ReferenceAcquisitionResult): void { + if (playerUrl !== undefined) URL.revokeObjectURL(playerUrl) + playerUrl = URL.createObjectURL( + new Blob([result.plaintext.slice().buffer], { type: asset.mediaType }) + ) + const element = asset.mediaType.startsWith('video/') ? 'video' : 'audio' + document.querySelector('#media-stage')!.innerHTML = + `<${element} controls src="${playerUrl}">
authenticated segments · signed license
` + acquireButton.textContent = 'Licensed asset ready' + checksButton.disabled = false + editButtons.forEach(button => (button.disabled = false)) + recoveryButton.disabled = result.authorizedOutputs.length === 0 + const receiptState = + buyerWallet === fixtureBuyerWallet + ? `${recordingWallet.receivedSatoshis} sat recording + ${compositionWallet.receivedSatoshis} sat composition` + : `${result.receipts.length} signed payee receipts` + document.querySelector('#settlement-receipt')!.innerHTML = receiptRows([ + ['TRANSACTION', short(result.transactionId)], + ['TRANSACTION EVIDENCE', result.transactionState], + ['RECORDING WALLET', `${recordingWallet.receivedSatoshis} sat internalized`], + ['RECORDING ENDPOINT', endpointLabel(server.payeeEndpoints[0]!.endpoint)], + ['COMPOSITION WALLET', `${compositionWallet.receivedSatoshis} sat internalized`], + ['COMPOSITION ENDPOINT', endpointLabel(server.payeeEndpoints[1]!.endpoint)], + ['PAYEE RECEIPTS', String(result.receipts.length)], + ['AUTHORIZED OUTPUT PROOFS', String(result.authorizedOutputs.length)], + [ + 'DELIVERY AVAILABILITY', + result.authorizedOutputs.length === 0 ? 'not used' : 'signed through recovery deadline' + ], + [ + 'LATE PAYEE RECOVERY', + result.authorizedOutputs.length === 0 ? 'not needed' : 'stored Delivery available' + ], + ['LICENSE', short(toHex(result.licenseId))], + ['RECOVERY', result.recovered ? 'verified' : 'not verified'] + ]) + status(`transaction ${short(result.transactionId)} · ${receiptState} · license recovery verified`) + const settlementCard = document.querySelector( + `[data-profile="${LCH_SETTLEMENT_PROFILES.authorizedOutput}"]` + )! + settlementCard.classList.add('passed') + settlementCard.querySelector('span')!.textContent = 'pass' + settlementCard.querySelector('ul')!.innerHTML = result.authorizedOutputs.length + ? '
  • Payee offline after signed readiness
  • exact output independently verified
  • accepted transaction + durable Delivery attested
  • License issued before late wallet internalization
  • ' + : '
  • online Payee Receipt remains valid
  • fallback was not needed
  • ' +} + +function renderAcquisitionError(error: unknown): void { + const pending = client.pendingPayment() + const requiresRecovery = pending !== undefined + if (!requiresRecovery) pendingPlan = undefined + acquireButton.textContent = requiresRecovery + ? 'Retry delivery & License recovery' + : 'Preflight & quote' + acquireButton.disabled = false + if (pending !== undefined) + document.querySelector('#settlement-receipt')!.innerHTML = receiptRows([ + ['TRANSACTION', short(pending.transactionId)], + ['TRANSACTION STATE', `${pending.transactionState} · broadcast not established`], + ['SETTLEMENT', pending.settlementState], + [ + 'SETTLEMENT PROOFS', + `${pending.receipts + pending.authorizedOutputs} / ${pending.requiredProofs}` + ], + ['RECOVERY UNTIL', new Date(Number(pending.recoveryUntil) * 1_000).toISOString()] + ]) + status(error instanceof Error ? error.message : 'acquisition failed') +} + +async function recoverRecordingPayment(): Promise { + recoveryButton.disabled = true + try { + server.setPayeeOnline('recording controller', true) + const receipts = await server.recoverStoredPayments('recording controller') + updateReceiptRow('RECORDING WALLET', `${recordingWallet.receivedSatoshis} sat internalized`) + updateReceiptRow( + 'LATE PAYEE RECOVERY', + `${receipts.length} Receipt${receipts.length === 1 ? '' : 's'} · internalized once` + ) + status( + `${receipts.length} late Receipt${receipts.length === 1 ? '' : 's'} recovered · recording wallet internalized ${recordingWallet.receivedSatoshis} sat` + ) + recoveryButton.textContent = 'Stored Delivery recovered by recording controller' + } catch (error) { + recoveryButton.disabled = false + status(error instanceof Error ? error.message : 'late Delivery recovery failed') + } +} + +async function runChecks(): Promise { + if (current === undefined || currentLicenseId === undefined) return + checksButton.disabled = true + status('running profile edge cases') + try { + const checks = await runCoreProfileChecks(current.assetId, currentLicenseId) + checks.forEach(check => { + const card = document.querySelector(`[data-profile="${check.profile}"]`)! + card.classList.add('passed') + card.querySelector('span')!.textContent = 'pass' + card.querySelector('ul')!.innerHTML = check.observations + .map(item => `
  • ${item}
  • `) + .join('') + }) + document.querySelector('#profile-output')!.textContent = JSON.stringify(checks, null, 2) + status('all six initial profiles passed') + } catch (error) { + document.querySelector('#profile-output')!.textContent = String(error) + status('profile check failed') + } finally { + checksButton.disabled = false + } +} + +function place(template: Omit): void { + const placement: EditorialPlacement = { id: placements.length + 1, ...template } + placements = [...placements, placement] + const clips = document.querySelector('#clips')! + if (placements.length === 1) clips.replaceChildren() + const clip = document.createElement('button') + clip.className = `clip ${placement.kind}` + clip.textContent = `${placement.id}. ${placement.label}` + clips.append(clip) + manifestButton.disabled = false + previewButton.disabled = false + status(`${placements.length} whole placement${placements.length === 1 ? '' : 's'} staged`) +} + +function previewLastEdit(): void { + const placement = placements.at(-1) + if (current === undefined || placement === undefined) return + if (current.mediaType !== 'audio/wav') { + status('PCM edit preview requires the generated WAV fixture') + return + } + try { + const transformed = transformToneWav(current.plaintext, placement) + if (transformUrl !== undefined) URL.revokeObjectURL(transformUrl) + transformUrl = URL.createObjectURL( + new Blob([transformed.slice().buffer], { type: 'audio/wav' }) + ) + const audio = document.querySelector('#edit-preview')! + audio.src = transformUrl + audio.hidden = false + void audio.play() + status(`${placement.label} PCM transform rendered locally`) + } catch (error) { + status(error instanceof Error ? error.message : 'edit preview failed') + } +} + +async function buildComposition(): Promise { + if (current === undefined || currentLicenseId === undefined || placements.length === 0) return + const record = await buildEditorialComposition(current.assetId, currentLicenseId, placements) + document.querySelector('#composition-output')!.textContent = JSON.stringify( + diagnostic(record), + null, + 2 + ) + status(`${placements.length} distinct C2PA ingredient bindings built`) +} + +async function createServer( + recordingSatoshis: number, + compositionSatoshis: number, + recordingSettlementProfile: string = LCH_SETTLEMENT_PROFILES.authorizedOutput +): Promise { + return ReferenceLCHServer.create({ + issuerWallet, + publicBaseUrl: referenceOrigin, + payees: [ + { + wallet: recordingWallet, + satoshis: recordingSatoshis, + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller', + settlementProfile: recordingSettlementProfile + }, + { + wallet: compositionWallet, + satoshis: compositionSatoshis, + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] + }) +} + +function createClient( + referenceServer: ReferenceLCHServer, + wallet: WalletInterface +): ReferenceLCHClient { + return new ReferenceLCHClient(wallet, referenceServer.content, { + endpointPolicy: { + allowLocalOrigins: [referenceOrigin], + connect: async (url, init) => referenceServer.http.handle(new Request(url, init)) + } + }) +} + +function exactPrice(id: string): number { + const value = Number(document.querySelector(`#${id}`)!.value) + if (!Number.isSafeInteger(value) || value <= 0) + throw new Error('Prices must be positive integers') + return value +} + +function diagnostic(value: unknown): unknown { + if (value instanceof Uint8Array) return { $bytes: toHex(value) } + if (typeof value === 'bigint') return { $uint: value.toString() } + if (Array.isArray(value)) return value.map(diagnostic) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, diagnostic(item)])) + } + return value +} + +function receiptRows(rows: Array<[string, string]>): string { + return rows + .map(([label, value]) => `
    ${label}${value}
    `) + .join('') +} + +function updateReceiptRow(label: string, value: string): void { + const row = [...document.querySelectorAll('#settlement-receipt > div')].find( + candidate => candidate.querySelector('span')?.textContent === label + ) + const code = row?.querySelector('code') + if (code !== null && code !== undefined) code.textContent = value +} + +function resetProfileCards(): void { + document.querySelectorAll('#profile-grid article').forEach(card => { + card.classList.remove('passed') + card.querySelector('span')!.textContent = 'pending' + card.querySelector('ul')!.innerHTML = '
  • awaiting licensed fixture
  • ' + }) +} + +function profileLabel(value: string): string { + return value.replaceAll(/([A-Z])/gu, ' $1').replace(/^./u, character => character.toUpperCase()) +} + +function fragment(value: string): string { + return value.slice(value.indexOf('#') + 1) +} + +function short(value: string): string { + return `${value.slice(0, 12)}…${value.slice(-8)}` +} + +function endpointLabel(value: string): string { + const url = new URL(value) + return `${url.host}${url.pathname}` +} + +function status(message: string): void { + document.querySelector('.network span')!.textContent = message +} diff --git a/apps/lch-reference/src/nodeServer.ts b/apps/lch-reference/src/nodeServer.ts new file mode 100644 index 000000000..59e009054 --- /dev/null +++ b/apps/lch-reference/src/nodeServer.ts @@ -0,0 +1,313 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { + createServer as createHttpServer, + type IncomingMessage, + type ServerResponse +} from 'node:http' +import { extname, join, normalize } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import type { WalletInterface } from '@bsv/sdk' +import { LCH_SETTLEMENT_PROFILES } from '@bsv/lch' +import { createFixtureWallet } from './fixtureWallet.js' +import { ReferenceLCHServer, referenceApiResponse } from './referenceServer.js' + +interface WalletSet { + issuerWallet: WalletInterface + recordingWallet: WalletInterface + compositionWallet: WalletInterface +} + +interface WalletModule { + createLCHWallets(): Promise +} + +const port = environmentInteger('PORT', 4173) +const publicBaseUrl = process.env.LCH_PUBLIC_BASE_URL ?? `http://127.0.0.1:${port}` +const staticDirectory = + process.env.LCH_STATIC_DIR ?? fileURLToPath(new URL('../dist', import.meta.url)) +const walletModule = process.env.LCH_WALLET_MODULE +const walletMode = walletModule === undefined ? 'fixture' : 'connected' +const wallets = await loadWallets(walletModule) +const lch = await ReferenceLCHServer.create({ + issuerWallet: wallets.issuerWallet, + publicBaseUrl, + payees: [ + { + wallet: wallets.recordingWallet, + satoshis: environmentInteger('LCH_RECORDING_SATOSHIS', 7), + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller', + settlementProfile: LCH_SETTLEMENT_PROFILES.authorizedOutput + }, + { + wallet: wallets.compositionWallet, + satoshis: environmentInteger('LCH_COMPOSITION_SATOSHIS', 5), + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] +}) + +const server = createHttpServer((request, response) => { + void route(request, response).catch(error => { + const message = error instanceof Error ? error.message : 'request failed' + const status = errorStatus(error) + if (!response.headersSent) sendJson(response, status, { error: message }) + else response.end() + }) +}) + +server.listen(port, '0.0.0.0', () => { + process.stdout.write( + `LCH reference server listening on ${publicBaseUrl} (${walletMode} wallets)\n` + ) +}) + +async function route(request: IncomingMessage, response: ServerResponse): Promise { + const url = new URL(request.url ?? '/', publicBaseUrl) + if (request.method === 'GET' && url.pathname === '/api/health') { + sendJson(response, 200, { + status: 'ready', + walletMode, + acquisitionEndpoint: lch.acquisitionEndpoint, + evidenceEndpoint: lch.evidenceEndpoint, + deliveryEndpoint: lch.deliveryEndpoint, + retrievalEndpoint: lch.retrievalEndpoint, + payeeEndpoints: lch.payeeEndpoints, + contentAdapter: 'reference-memory' + }) + return + } + if (request.method === 'POST' && url.pathname === '/api/assets') { + await publishAsset(request, response) + return + } + if (request.method === 'POST' && isLCHPath(url.pathname)) { + const body = await requestBytes(request, 16 * 1024 * 1024) + const fetchRequest = new Request(new URL(url.pathname, publicBaseUrl), { + method: 'POST', + headers: requestHeaders(request), + body: body.slice().buffer + }) + await sendFetchResponse(response, await lch.http.handle(fetchRequest)) + return + } + if (request.method === 'OPTIONS' && isLCHPath(url.pathname)) { + await sendFetchResponse( + response, + await lch.http.handle( + new Request(new URL(url.pathname, publicBaseUrl), { + method: 'OPTIONS', + headers: requestHeaders(request) + }) + ) + ) + return + } + if (request.method === 'GET' && url.pathname.startsWith('/content/')) { + serveContent(response, request.headers.range, url.pathname) + return + } + if (request.method === 'GET' || request.method === 'HEAD') { + await sendStatic(response, request.method, url.pathname) + return + } + response.writeHead(405, { allow: 'GET, HEAD, POST, OPTIONS' }).end() +} + +function isLCHPath(pathname: string): boolean { + return pathname === '/api/lch' || pathname.startsWith('/api/lch/') +} + +async function loadWallets(moduleSpecifier: string | undefined): Promise { + if (moduleSpecifier === undefined) { + return { + issuerWallet: createFixtureWallet(101), + recordingWallet: createFixtureWallet(102), + compositionWallet: createFixtureWallet(103) + } + } + const specifier = walletModuleSpecifier(moduleSpecifier) + const loaded = (await import(/* @vite-ignore */ specifier)) as Partial + if (typeof loaded.createLCHWallets !== 'function') + throw new TypeError('LCH_WALLET_MODULE must export createLCHWallets()') + return loaded.createLCHWallets() +} + +async function publishAsset(request: IncomingMessage, response: ServerResponse): Promise { + const body = JSON.parse( + new TextDecoder().decode(await requestBytes(request, 32 * 1024 * 1024)) + ) as { + name?: unknown + mediaType?: unknown + bytesBase64?: unknown + } + if ( + typeof body.name !== 'string' || + typeof body.mediaType !== 'string' || + typeof body.bytesBase64 !== 'string' + ) { + sendJson(response, 400, { error: 'name, mediaType, and bytesBase64 are required' }) + return + } + const published = await lch.publish({ + name: body.name, + mediaType: body.mediaType, + bytes: Uint8Array.from(Buffer.from(body.bytesBase64, 'base64')) + }) + await sendFetchResponse(response, referenceApiResponse(published)) +} + +function serveContent(response: ServerResponse, range: string | undefined, pathname: string): void { + const key = pathname.slice('/content/'.length) + if (!/^[0-9a-f]{64}$/u.test(key)) { + response.writeHead(404).end() + return + } + const bytes = lch.content.get(key) + if (bytes === undefined) { + response.writeHead(404).end() + return + } + sendContent(response, range, bytes) +} + +function walletModuleSpecifier(value: string): string { + if (value.startsWith('.')) return pathToFileURL(join(process.cwd(), value)).href + if (value.startsWith('/')) return pathToFileURL(value).href + return value +} + +function errorStatus(error: unknown): number { + if (error instanceof SyntaxError) return 400 + if (error instanceof RangeError) return 413 + return 500 +} + +function requestBytes(request: IncomingMessage, maximum: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = [] + let length = 0 + let failed = false + request.on('data', (chunk: Buffer) => { + length += chunk.length + if (length > maximum && !failed) { + failed = true + reject(new RangeError('request body exceeds its limit')) + } else if (!failed) chunks.push(chunk) + }) + request.on('end', () => { + if (!failed) resolve(Uint8Array.from(Buffer.concat(chunks))) + }) + request.on('error', reject) + }) +} + +function requestHeaders(request: IncomingMessage): Headers { + const headers = new Headers() + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value) + } + return headers +} + +async function sendFetchResponse(output: ServerResponse, input: Response): Promise { + const headers = Object.fromEntries(input.headers.entries()) + const bytes = input.body === null ? undefined : Buffer.from(await input.arrayBuffer()) + output.writeHead(input.status, headers) + output.end(bytes) +} + +function sendJson(response: ServerResponse, status: number, value: unknown): void { + const body = Buffer.from(JSON.stringify(value)) + response + .writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': body.length, + 'cache-control': 'no-store' + }) + .end(body) +} + +function sendContent(response: ServerResponse, range: string | undefined, bytes: Uint8Array): void { + const parsed = range === undefined ? undefined : /^bytes=(\d+)-(\d*)$/u.exec(range) + if (parsed === null) { + response.writeHead(416, { 'content-range': `bytes */${bytes.length}` }).end() + return + } + const start = parsed === undefined ? 0 : Number(parsed[1]) + const end = parsed === undefined || parsed[2] === '' ? bytes.length : Number(parsed[2]) + 1 + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end <= start || + end > bytes.length + ) { + response.writeHead(416, { 'content-range': `bytes */${bytes.length}` }).end() + return + } + const body = bytes.slice(start, end) + response + .writeHead(parsed === undefined ? 200 : 206, { + 'content-type': 'application/octet-stream', + 'content-length': body.length, + 'accept-ranges': 'bytes', + ...(parsed === undefined + ? {} + : { 'content-range': `bytes ${start}-${end - 1}/${bytes.length}` }) + }) + .end(body) +} + +async function sendStatic( + response: ServerResponse, + method: string, + pathname: string +): Promise { + const relative = pathname === '/' ? 'index.html' : normalize(pathname).replace(/^[/\\]+/u, '') + if (relative.startsWith('..')) { + response.writeHead(404).end() + return + } + let filename = join(staticDirectory, relative) + let details + try { + details = await stat(filename) + if (!details.isFile()) throw new Error('not a file') + } catch { + filename = join(staticDirectory, 'index.html') + details = await stat(filename) + } + response.writeHead(200, { + 'content-type': mediaType(filename), + 'content-length': details.size, + 'cache-control': filename.endsWith('index.html') + ? 'no-cache' + : 'public, max-age=31536000, immutable' + }) + if (method === 'HEAD') response.end() + else createReadStream(filename).pipe(response) +} + +function mediaType(filename: string): string { + return ( + { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.md': 'text/markdown; charset=utf-8' + }[extname(filename)] ?? 'application/octet-stream' + ) +} + +function environmentInteger(name: string, fallback: number): number { + const raw = process.env[name] + const value = raw === undefined ? fallback : Number(raw) + if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be positive`) + return value +} diff --git a/apps/lch-reference/src/referenceClient.ts b/apps/lch-reference/src/referenceClient.ts new file mode 100644 index 000000000..cf328effe --- /dev/null +++ b/apps/lch-reference/src/referenceClient.ts @@ -0,0 +1,240 @@ +import { Transaction, type AtomicBEEF, type WalletInterface } from '@bsv/sdk' +import { + LCHMultipayBuyer, + LCHReader, + PublicBRC77Verifier, + WalletBRC78KeyDelivery, + objectId, + toHex, + validateOffer, + type ContentSource, + type AuthorizedOutputEvidence, + type EndpointPolicy, + type InspectedLCH, + type LCHFundedMultipay, + type LCHMultipayPlan, + type LCHTransactionState, + type LCHValue, + type SignedObject +} from '@bsv/lch' + +export interface ReferenceAcquisitionPlan extends LCHMultipayPlan { + bytes: Uint8Array + inspected: InspectedLCH + offer: SignedObject + offerId: Uint8Array +} + +export interface ReferenceAcquisitionResult { + license: SignedObject + licenseId: Uint8Array + plaintext: Uint8Array + receipts: SignedObject[] + authorizedOutputs: AuthorizedOutputEvidence[] + transactionId: string + transactionState: LCHTransactionState + recovered: boolean +} + +export interface ReferencePendingPayment { + requestId: string + transactionId: string + transactionState: LCHTransactionState + settlementState: 'pending-settlement-proofs' + receipts: number + authorizedOutputs: number + requiredProofs: number + recoveryUntil: bigint +} + +export class ReferenceLCHClient { + private readonly reader: LCHReader + private readonly multipay: Promise + private readonly now: () => bigint + private recoveryState?: { + requestId: string + payment: LCHFundedMultipay + receipts: Map + authorizedOutputs: Map + } + + constructor( + private readonly wallet: WalletInterface, + source: ContentSource, + options: { endpointPolicy?: EndpointPolicy; now?: () => bigint } = {} + ) { + this.reader = new LCHReader(source) + this.multipay = LCHMultipayBuyer.create(wallet, options) + this.now = options.now ?? (() => BigInt(Math.floor(Date.now() / 1000))) + } + + async prepare(bytes: Uint8Array): Promise { + const inspected = await this.reader.inspect(bytes) + await this.reader.resolve(inspected) + const offer = inlineOffer(inspected.header.acquisition) + const seller = memberBytes(offer.body, 'seller', 33) + await validateOffer(offer, new PublicBRC77Verifier(), seller) + equal(offer.body.assetId, inspected.assetId, 'Offer Asset ID') + const offerId = await objectId('offer', offer.body) + const payment = memberMap(offer.body, 'payment') + const endpoint = memberString(payment, 'endpoint') + const policy = memberMap(offer.body, 'policy') + const multipay = await this.multipay + const request = await multipay.createRequest({ + offerId, + assetId: inspected.assetId, + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest: memberBytes(policy, 'digest', 32), + createdAt: this.now() + }) + const issuer = memberBytes(offer.body, 'licenseIssuer', 33) + const paymentPlan = await multipay.quote(endpoint, request, issuer) + return { + ...paymentPlan, + bytes, + inspected, + offer, + offerId + } + } + + async acquire(plan: ReferenceAcquisitionPlan): Promise { + const multipay = await this.multipay + const requestId = toHex(plan.requestId) + if (this.recoveryState !== undefined && this.recoveryState.requestId !== requestId) + throw new Error('Another wallet transaction still requires delivery or License recovery') + this.recoveryState ??= { + requestId, + payment: await multipay.createPayment(await multipay.refreshReadiness(plan)), + receipts: new Map(), + authorizedOutputs: new Map() + } + const { + payment, + receipts: recoveredReceipts, + authorizedOutputs: recoveredAuthorizedOutputs + } = this.recoveryState + for (const delivery of payment.deliveries) { + const demandId = toHex(delivery.demandId) + if (recoveredReceipts.has(demandId) || recoveredAuthorizedOutputs.has(demandId)) continue + const settlement = await multipay.settleDelivery(payment, delivery) + if (settlement.type === 'receipt') recoveredReceipts.set(demandId, settlement.receipt) + else recoveredAuthorizedOutputs.set(demandId, settlement.evidence) + } + const receipts = [...recoveredReceipts.values()] + const authorizedOutputs = [...recoveredAuthorizedOutputs.values()] + const license = await multipay.complete(payment, receipts, authorizedOutputs) + equal(license.body.assetId, plan.inspected.assetId, 'License Asset ID') + equal(license.body.offerId, plan.offerId, 'License Offer ID') + + const keys = new Map() + const keyDelivery = new WalletBRC78KeyDelivery(this.wallet) + for (const grant of mapArray(license.body.keyGrants, 'License key grants')) { + const payload = memberBytes(grant, 'payload') + const recovered = await keyDelivery.recover(payload) + equal(grant.keyId, recovered.keyId, 'Recovered key ID') + keys.set(toHex(recovered.keyId), recovered.cek) + } + const plaintext = await this.reader.decrypt(plan.inspected, keys) + const licenseId = await objectId('license', license.body) + const recovered = await multipay.recover(plan.endpoint, plan.requestId) + if ( + recovered === undefined || + toHex(await objectId('license', recovered.body)) !== toHex(licenseId) + ) + throw new Error('License recovery did not return the issued License') + this.recoveryState = undefined + return { + license, + licenseId, + plaintext, + receipts, + authorizedOutputs, + transactionId: Transaction.fromAtomicBEEF(payment.atomicBeef as AtomicBEEF).id('hex'), + transactionState: authorizedOutputs.length > 0 ? 'accepted' : payment.transactionState, + recovered: true + } + } + + hasPendingPayment(): boolean { + return this.recoveryState !== undefined + } + + pendingPayment(): ReferencePendingPayment | undefined { + const state = this.recoveryState + if (state === undefined) return undefined + const transaction = Transaction.fromAtomicBEEF(state.payment.atomicBeef as AtomicBEEF) + return { + requestId: state.requestId, + transactionId: transaction.id('hex'), + transactionState: + state.authorizedOutputs.size > 0 ? 'accepted' : state.payment.transactionState, + settlementState: 'pending-settlement-proofs', + receipts: state.receipts.size, + authorizedOutputs: state.authorizedOutputs.size, + requiredProofs: state.payment.deliveries.length, + recoveryUntil: state.payment.plan.recoveryUntil + } + } +} + +function inlineOffer(value: LCHValue | undefined): SignedObject { + if (!Array.isArray(value)) throw new Error('Header acquisition entries are invalid') + for (const entry of value) { + if (isMap(entry) && entry.mode === 'inline') return signed(entry.offer) + } + throw new Error('Reference client requires an inline Offer') +} + +function signed(value: LCHValue | undefined): SignedObject { + const map = asMap(value, 'Signed Object') + if (!Array.isArray(map.signatures) || !map.signatures.every(item => item instanceof Uint8Array)) + throw new Error('Signed Object signatures are invalid') + return { + body: asMap(map.body, 'Signed Object body'), + signatures: map.signatures as Uint8Array[] + } +} + +function memberMap(body: Record, key: string): Record { + return asMap(body[key], key) +} + +function mapArray(value: LCHValue | undefined, name: string): Array> { + if (!Array.isArray(value)) throw new Error(`${name} is invalid`) + return value.map(item => asMap(item, name)) +} + +function asMap(value: LCHValue | undefined, name: string): Record { + if (!isMap(value)) throw new Error(`${name} is invalid`) + return value +} + +function isMap(value: LCHValue | undefined): value is Record { + return ( + value !== undefined && + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array) + ) +} + +function memberBytes(body: Record, key: string, length?: number): Uint8Array { + const value = body[key] + if (!(value instanceof Uint8Array) || (length !== undefined && value.length !== length)) + throw new Error(`${key} is invalid`) + return value +} + +function memberString(body: Record, key: string): string { + const value = body[key] + if (typeof value !== 'string' || value.length === 0) throw new Error(`${key} is invalid`) + return value +} + +function equal(value: LCHValue | undefined, expected: Uint8Array, name: string): void { + if (!(value instanceof Uint8Array) || toHex(value) !== toHex(expected)) + throw new Error(`${name} does not match`) +} diff --git a/apps/lch-reference/src/referenceServer.ts b/apps/lch-reference/src/referenceServer.ts new file mode 100644 index 000000000..3d4dc186f --- /dev/null +++ b/apps/lch-reference/src/referenceServer.ts @@ -0,0 +1,954 @@ +import { Transaction, type AtomicBEEF, type WalletInterface } from '@bsv/sdk' +import { + LCHHttpServer, + LCHError, + LCHIssuer, + LCHPayee, + LCHPublisher, + LCHQuoteIssuer, + LCHReader, + LCHSettlementService, + LCH_MECHANISMS, + LCH_PROFILES, + LCH_SETTLEMENT_PROFILES, + LCH_TRANSACTION_EVIDENCE_POLICIES, + WalletBRC77Signer, + WalletAuthorizedOutputPayee, + WalletBRC78KeyDelivery, + WalletPaymentReceiver, + encodeDeterministicCbor, + objectId, + sha256, + toBase64Url, + toHex, + validateLicenseRequest, + validateAuthorizedOutputEvidence, + validatePaymentAuthorization, + validatePaymentDelivery, + validatePaymentDeliveryRetrieval, + validatePaymentReceipt, + type AuthorizedOutputEvidence, + type ContentSink, + type ContentSource, + type LCHValue, + type PaymentCompletion, + type PaymentDeliveryStoreRequest, + type ProtectedAsset, + type SignedObject, + type StoredPaymentDelivery, + type TransactionEvidenceRequest +} from '@bsv/lch' + +export interface ReferencePayeeOptions { + wallet: WalletInterface + satoshis: number + dutyUid: string + interest: string + label: string + endpoint?: string + settlementProfile?: string +} + +export interface ReferenceLCHServerOptions { + issuerWallet: WalletInterface + payees: ReferencePayeeOptions[] + publicBaseUrl: string + now?: () => bigint +} + +export interface ReferencePublishRequest { + bytes: Uint8Array + mediaType: string + name: string +} + +export interface ReferencePublishedAsset { + assetId: Uint8Array + offerId: Uint8Array + lch: Uint8Array + acquisitionEndpoint: string +} + +interface PayeeRuntime extends Omit { + endpoint: string + identityKey: Uint8Array + payee: LCHPayee + receiver: WalletPaymentReceiver + authorizer: WalletAuthorizedOutputPayee + online: boolean + offlineAfterNextReadiness: boolean +} + +interface StoredDelivery { + authorization: SignedObject + delivery: SignedObject + acknowledgement: SignedObject + payee: PayeeRuntime +} + +interface DeliveryClaim { + authorizationBytes: Uint8Array + deliveryBytes: Uint8Array + completion: Promise +} + +interface AssetRecord extends ReferencePublishedAsset { + protectedAsset: ProtectedAsset + offer: SignedObject + policyDigest: Uint8Array + mediaType: string + name: string +} + +interface QuoteRecord { + asset: AssetRecord + request: SignedObject + quote: SignedObject + demands: Map +} + +export class ReferenceContentStore implements ContentSink, ContentSource { + private readonly values = new Map() + + constructor(private readonly publicBaseUrl: string) {} + + async put(ciphertext: Uint8Array): Promise { + const key = toHex(await sha256(ciphertext)) + this.values.set(key, ciphertext.slice()) + return [`${this.publicBaseUrl}/content/${key}`] + } + + async read(locator: string, start = 0n, end?: bigint): Promise { + const key = new URL(locator).pathname.split('/').at(-1) + const value = key === undefined ? undefined : this.values.get(key) + if (value === undefined) throw new Error('Reference content is unavailable') + return value.slice(Number(start), end === undefined ? undefined : Number(end)) + } + + get(key: string): Uint8Array | undefined { + return this.values.get(key)?.slice() + } +} + +export class ReferenceLCHServer { + readonly acquisitionEndpoint: string + readonly evidenceEndpoint: string + readonly deliveryEndpoint: string + readonly retrievalEndpoint: string + readonly payeeEndpoints: ReadonlyArray<{ label: string; endpoint: string }> + readonly content: ReferenceContentStore + readonly http: Pick + + private readonly now: () => bigint + private readonly issuer: LCHIssuer + private readonly publisher: LCHPublisher + private readonly quoteIssuer: LCHQuoteIssuer + private readonly keyDelivery: WalletBRC78KeyDelivery + private readonly settlementService: LCHSettlementService + private readonly payees: PayeeRuntime[] + private readonly assets = new Map() + private readonly offers = new Map() + private readonly quotes = new Map() + private readonly demandIndex = new Map() + private readonly licenses = new Map() + private readonly storedDeliveries = new Map() + private readonly deliveryClaims = new Map() + private readonly transactionEvidence = new Map() + private readonly acceptedTransactions = new Map() + private availabilityProviderOnline = true + private evidenceProviderOnline = true + + private constructor( + options: ReferenceLCHServerOptions, + private readonly issuerIdentity: Uint8Array, + issuer: LCHIssuer, + publisher: LCHPublisher, + quoteIssuer: LCHQuoteIssuer, + keyDelivery: WalletBRC78KeyDelivery, + settlementService: LCHSettlementService, + payees: PayeeRuntime[] + ) { + this.acquisitionEndpoint = `${options.publicBaseUrl}/api/lch` + this.evidenceEndpoint = `${options.publicBaseUrl}/api/lch/evidence` + this.deliveryEndpoint = `${options.publicBaseUrl}/api/lch/delivery-store` + this.retrievalEndpoint = `${options.publicBaseUrl}/api/lch/delivery-retrieval` + this.content = new ReferenceContentStore(options.publicBaseUrl) + this.now = options.now ?? (() => BigInt(Math.floor(Date.now() / 1000))) + this.issuer = issuer + this.publisher = publisher + this.quoteIssuer = quoteIssuer + this.keyDelivery = keyDelivery + this.settlementService = settlementService + this.payees = payees + this.payeeEndpoints = payees.map(({ label, endpoint }) => ({ label, endpoint })) + const issuerHttp = new LCHHttpServer({ + handlers: { + preflightLicense: request => this.preflightLicense(request), + quote: request => this.quote(request), + complete: completion => this.complete(completion), + recover: requestId => this.recover(requestId) + } + }) + const payeeHttp = new Map( + payees.map( + payee => + [ + payee.endpoint, + new LCHHttpServer({ + handlers: { + preflightDemand: demand => this.preflightDemandFor(payee, demand), + authorizePayment: demand => this.authorizePaymentFor(payee, demand), + paymentDelivery: delivery => this.receivePaymentFor(payee, delivery) + } + }) + ] as const + ) + ) + const evidenceHttp = new LCHHttpServer({ + handlers: { attestTransaction: request => this.attestTransaction(request) } + }) + const deliveryHttp = new LCHHttpServer({ + handlers: { storeDelivery: request => this.storeDelivery(request) } + }) + const retrievalHttp = new LCHHttpServer({ + handlers: { retrieveDelivery: request => this.retrieveDelivery(request) } + }) + const endpointHandlers = new Map(payeeHttp) + endpointHandlers.set(this.acquisitionEndpoint, issuerHttp) + endpointHandlers.set(this.evidenceEndpoint, evidenceHttp) + endpointHandlers.set(this.deliveryEndpoint, deliveryHttp) + endpointHandlers.set(this.retrievalEndpoint, retrievalHttp) + this.http = { + handle: request => { + const endpoint = requestEndpoint(request.url) + const handler = endpointHandlers.get(endpoint) + return handler?.handle(request) ?? Promise.resolve(new Response(null, { status: 404 })) + } + } + } + + static async create(options: ReferenceLCHServerOptions): Promise { + if (options.payees.length < 2) + throw new TypeError('The multilateral reference flow requires at least two Payee wallets') + const issuerSigner = await WalletBRC77Signer.create({ wallet: options.issuerWallet }) + const now = options.now ?? (() => BigInt(Math.floor(Date.now() / 1000))) + const payees = await Promise.all( + options.payees.map(async payeeOptions => { + const signer = await WalletBRC77Signer.create({ wallet: payeeOptions.wallet }) + const endpoint = + payeeOptions.endpoint ?? + `${options.publicBaseUrl}/api/lch/payees/${encodeURIComponent(payeeOptions.interest)}` + return { + ...payeeOptions, + settlementProfile: + payeeOptions.settlementProfile ?? LCH_SETTLEMENT_PROFILES.receiptComplete, + endpoint, + identityKey: signer.identityKey, + payee: new LCHPayee(signer), + authorizer: new WalletAuthorizedOutputPayee({ + wallet: payeeOptions.wallet, + signer, + now, + allowInsecureLocalOrigins: isLocalHttp(endpoint) ? [new URL(endpoint).origin] : [] + }), + online: true, + offlineAfterNextReadiness: false, + receiver: new WalletPaymentReceiver({ + wallet: payeeOptions.wallet, + signer, + now, + allowInsecureLocalOrigins: isLocalHttp(endpoint) ? [new URL(endpoint).origin] : [] + }) + } + }) + ) + return new ReferenceLCHServer( + options, + issuerSigner.identityKey, + new LCHIssuer(issuerSigner), + new LCHPublisher(issuerSigner), + new LCHQuoteIssuer(issuerSigner), + new WalletBRC78KeyDelivery(options.issuerWallet), + new LCHSettlementService(issuerSigner), + payees + ) + } + + async publish(input: ReferencePublishRequest): Promise { + const protectedAsset = await this.publisher.protect(input.bytes, { + mediaType: input.mediaType, + name: input.name, + rights: [ + { + interest: 'licensed-work', + holder: { name: 'LCH reference creator' }, + controller: this.issuerIdentity + } + ], + sink: this.content, + segmentSize: 16 * 1024, + keyPeriodSegments: 1 + }) + const target = `lch:asset:sha256:${toHex(protectedAsset.assetId)}` + const duties = this.payees.map(payee => ({ + uid: payee.dutyUid, + action: 'compensate', + compensatedParty: `lch:identity:secp256k1:${toHex(payee.identityKey)}`, + payAmount: { value: payee.satoshis, unit: 'lchv:satoshi' } + })) + const policyBytes = new TextEncoder().encode( + JSON.stringify({ + '@context': ['http://www.w3.org/ns/odrl.jsonld'], + '@type': 'Offer', + uid: 'lch:offer:self', + profile: 'https://bsv.brc.dev/apps/0170#odrl-profile', + permission: [ + { target, action: 'play', duty: duties }, + { target, action: 'derive', duty: duties } + ], + prohibition: [{ target, action: 'unwrap' }] + }) + ) + const policyDigest = await sha256(policyBytes) + const now = this.now() + const offer = await this.issuer.createOffer({ + assetId: protectedAsset.assetId, + usageProfile: LCH_PROFILES.fixedRender, + seller: this.issuerIdentity, + licenseIssuer: this.issuerIdentity, + requiredInterests: ['licensed-work'], + policy: { + mediaType: 'application/ld+json', + digest: policyDigest, + inline: policyBytes + }, + payment: { + protocol: LCH_MECHANISMS.brc105Multipay, + endpoint: this.acquisitionEndpoint, + asset: 'BSV', + unit: 'satoshi', + recoveryPeriodSeconds: 86_400, + pricing: { + kind: 'fixed', + requirements: this.payees.map(payee => ({ + dutyUid: payee.dutyUid, + payee: payee.identityKey, + endpoint: payee.endpoint, + satoshis: payee.satoshis, + interest: payee.interest + })) + } + }, + keyDelivery: { mechanism: LCH_MECHANISMS.brc78Key }, + enforcement: { + class: 'https://bsv.brc.dev/apps/0170#conformingApplication', + connectivity: 'https://bsv.brc.dev/apps/0170#either' + }, + notBefore: now, + nonce: crypto.getRandomValues(new Uint8Array(16)) + }) + const offerId = await objectId('offer', offer.body) + const published = await this.publisher.publish( + protectedAsset, + [{ mode: 'inline', offer } as unknown as Record], + false + ) + const record: AssetRecord = { + assetId: protectedAsset.assetId, + offerId, + lch: published.bytes, + acquisitionEndpoint: this.acquisitionEndpoint, + protectedAsset, + offer, + policyDigest, + mediaType: input.mediaType, + name: input.name + } + this.assets.set(toHex(record.assetId), record) + this.offers.set(toHex(record.offerId), record) + return publicAsset(record) + } + + asset(assetId: string): ReferencePublishedAsset | undefined { + const record = this.assets.get(assetId) + return record === undefined ? undefined : publicAsset(record) + } + + setPayeeOnline(label: string, online: boolean): void { + this.payeeByLabel(label).online = online + } + + setPayeeOfflineAfterNextReadiness(label: string, enabled = true): void { + this.payeeByLabel(label).offlineAfterNextReadiness = enabled + } + + setAvailabilityProviderOnline(online: boolean): void { + this.availabilityProviderOnline = online + } + + setEvidenceProviderOnline(online: boolean): void { + this.evidenceProviderOnline = online + } + + async recoverStoredPayments(label: string): Promise { + const payee = this.payeeByLabel(label) + if (!payee.online) throw new Error('Payee endpoint is offline') + const receipts: SignedObject[] = [] + for (const stored of this.storedDeliveries.values()) { + if (stored.payee !== payee) continue + const request = await payee.payee.createDeliveryRetrieval({ + authorizationId: await objectId('payment-authorization', stored.authorization.body), + requestedAt: this.now() + }) + const retrieved = await this.retrieveDelivery(request) + if (retrieved === undefined) throw new Error('Stored Payment Delivery is unavailable') + receipts.push( + await payee.receiver.receive(this.demandFor(retrieved.delivery), retrieved.delivery) + ) + } + return receipts + } + + async preflightLicense(request: SignedObject): Promise { + const requestId = await validateLicenseRequest(request) + const asset = this.requestAsset(request) + this.validateRequestTerms(request, asset) + await new LCHReader(this.content).resolve(await new LCHReader(this.content).inspect(asset.lch)) + const existing = this.quotes.get(toHex(requestId)) + if (existing !== undefined && toHex(existing.asset.assetId) !== toHex(asset.assetId)) + throw new Error('Request ID was reused for another Asset') + } + + async quote(request: SignedObject): Promise { + await this.preflightLicense(request) + const requestId = await objectId('license-request', request.body) + const key = toHex(requestId) + const existing = this.quotes.get(key) + if (existing !== undefined) return existing.quote + const asset = this.requestAsset(request) + const expiresAt = this.now() + 300n + const buyer = bytes(request.body.buyer, 33, 'Request buyer identity') + const demands = new Map() + for (const payee of this.payees) { + const demand = await payee.payee.createDemand({ + requestId, + offerId: asset.offerId, + dutyUid: payee.dutyUid, + buyer, + endpoint: payee.endpoint, + satoshis: payee.satoshis, + expiresAt, + recoveryPeriodSeconds: 86_400, + settlementProfile: payee.settlementProfile, + allowInsecureLocalEndpoint: isLocalHttp(this.acquisitionEndpoint) + }) + const demandId = toHex(await objectId('payment-demand', demand.body)) + const runtime = { demand, payee } + demands.set(demandId, runtime) + this.demandIndex.set(demandId, runtime) + } + const quote = await this.quoteIssuer.createQuote({ + requestId, + offerId: asset.offerId, + assetId: asset.assetId, + buyer, + selection: selection(request.body.selection), + demands: [...demands.values()].map(item => item.demand), + expiresAt, + recoveryPeriodSeconds: 86_400 + }) + this.quotes.set(key, { asset, request, quote, demands }) + return quote + } + + async preflightDemand(demand: SignedObject): Promise { + const demandId = toHex(await objectId('payment-demand', demand.body)) + const runtime = this.demandIndex.get(demandId) + if (runtime === undefined) throw new Error('Payment Demand is unknown') + if (!runtime.payee.online) throw new Error('Payee endpoint is offline') + await runtime.payee.receiver.preflight(demand) + return this.createReadiness(runtime.payee, runtime.demand, hex(demandId)) + } + + async receivePayment(delivery: SignedObject): Promise { + const demandId = delivery.body.demandId + if (!(demandId instanceof Uint8Array)) throw new Error('Payment Delivery has no Demand ID') + const runtime = this.demandIndex.get(toHex(demandId)) + if (runtime === undefined) throw new Error('Payment Demand is unknown') + if (!runtime.payee.online) throw new Error('Payee endpoint is offline') + return runtime.payee.receiver.receive(runtime.demand, delivery) + } + + private async preflightDemandFor( + payee: PayeeRuntime, + demand: SignedObject + ): Promise { + const demandId = toHex(await objectId('payment-demand', demand.body)) + const runtime = this.demandIndex.get(demandId) + if (runtime?.payee !== payee) throw new Error('Payment Demand belongs to another endpoint') + if (!payee.online) throw new Error('Payee endpoint is offline') + await runtime.payee.receiver.preflight(demand) + const readiness = await this.createReadiness(payee, runtime.demand, hex(demandId)) + if (payee.offlineAfterNextReadiness) { + payee.offlineAfterNextReadiness = false + payee.online = false + } + return readiness + } + + private async authorizePaymentFor( + payee: PayeeRuntime, + demand: SignedObject + ): Promise { + const demandId = toHex(await objectId('payment-demand', demand.body)) + const runtime = this.demandIndex.get(demandId) + if (runtime?.payee !== payee) throw new Error('Payment Demand belongs to another endpoint') + if (!payee.online) throw new Error('Payee endpoint is offline') + return payee.authorizer.authorize(demand, { + evidenceProvider: this.issuerIdentity, + evidenceEndpoint: this.evidenceEndpoint, + evidencePolicy: LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance, + minimumTransactionState: 'accepted', + deliveryProvider: this.issuerIdentity, + deliveryEndpoint: this.deliveryEndpoint, + retrievalEndpoint: this.retrievalEndpoint, + allowInsecureLocalEndpoint: isLocalHttp(this.acquisitionEndpoint) + }) + } + + private createReadiness( + payee: PayeeRuntime, + demand: SignedObject, + demandId: Uint8Array + ): Promise { + const issuedAt = this.now() + const expiresAt = BigInt(demand.body.expiresAt as number | bigint) + const readyUntil = issuedAt + 60n < expiresAt ? issuedAt + 60n : expiresAt + return payee.payee.createReadiness({ + demandId, + requestId: bytes(demand.body.requestId, 32, 'Request ID'), + buyer: bytes(demand.body.buyer, 33, 'Buyer identity'), + issuedAt, + readyUntil, + recoveryUntil: BigInt(demand.body.recoveryUntil as number | bigint) + }) + } + + private async receivePaymentFor( + payee: PayeeRuntime, + delivery: SignedObject + ): Promise { + const demandId = delivery.body.demandId + if (!(demandId instanceof Uint8Array)) throw new Error('Payment Delivery has no Demand ID') + const runtime = this.demandIndex.get(toHex(demandId)) + if (runtime?.payee !== payee) throw new Error('Payment Demand belongs to another endpoint') + if (!payee.online) throw new Error('Payee endpoint is offline') + return runtime.payee.receiver.receive(runtime.demand, delivery) + } + + private async storeDelivery(request: PaymentDeliveryStoreRequest): Promise { + if (!this.availabilityProviderOnline) throw new Error('Delivery provider is unavailable') + const demandId = bytes(request.authorization.body.demandId, 32, 'Authorization Demand ID') + const runtime = this.demandIndex.get(toHex(demandId)) + if (runtime === undefined) throw new Error('Payment Authorization refers to an unknown Demand') + const authorizationId = await validatePaymentAuthorization( + request.authorization, + runtime.demand, + undefined, + undefined, + this.validationOptions() + ) + await validatePaymentDelivery(request.delivery) + equal(request.delivery.body.demandId, demandId, 'Delivery Demand ID') + const key = toHex(authorizationId) + const existing = this.storedDeliveries.get(key) + if (existing !== undefined) { + equalObject(existing.authorization, request.authorization, 'Stored Payment Authorization') + equalObject(existing.delivery, request.delivery, 'Stored Payment Delivery') + return existing.acknowledgement + } + const authorizationBytes = encodeDeterministicCbor(request.authorization as unknown as LCHValue) + const deliveryBytes = encodeDeterministicCbor(request.delivery as unknown as LCHValue) + const claimed = this.deliveryClaims.get(key) + if (claimed !== undefined) { + equal(authorizationBytes, claimed.authorizationBytes, 'Stored Payment Authorization') + equal(deliveryBytes, claimed.deliveryBytes, 'Stored Payment Delivery') + return claimed.completion + } + + // Claim the byte-exact Authorization/Delivery pair synchronously. This keeps + // concurrent requests idempotent and rejects a second Delivery before the + // acknowledgement signer yields. + const completion = Promise.resolve().then(async (): Promise => { + const acknowledgement = await this.settlementService.createDeliveryAcknowledgement({ + authorizationId, + deliveryId: await objectId('payment-delivery', request.delivery.body), + demandId, + requestId: bytes(request.delivery.body.requestId, 32, 'Delivery Request ID'), + payee: runtime.payee.identityKey, + storedAt: this.now(), + availableUntil: BigInt(request.authorization.body.recoveryUntil as number | bigint), + retrievalEndpoint: this.retrievalEndpoint, + allowInsecureLocalEndpoint: isLocalHttp(this.retrievalEndpoint) + }) + this.storedDeliveries.set(key, { + authorization: request.authorization, + delivery: request.delivery, + acknowledgement, + payee: runtime.payee + }) + return acknowledgement + }) + const claim = { authorizationBytes, deliveryBytes, completion } + this.deliveryClaims.set(key, claim) + try { + return await completion + } catch (error) { + if (this.deliveryClaims.get(key) === claim) this.deliveryClaims.delete(key) + throw error + } + } + + private async attestTransaction(request: TransactionEvidenceRequest): Promise { + if (!this.evidenceProviderOnline) + throw new Error('Transaction evidence provider is unavailable') + const demandId = bytes(request.authorization.body.demandId, 32, 'Authorization Demand ID') + const runtime = this.demandIndex.get(toHex(demandId)) + if (runtime === undefined) throw new Error('Payment Authorization refers to an unknown Demand') + const authorizationId = await validatePaymentAuthorization( + request.authorization, + runtime.demand, + undefined, + undefined, + this.validationOptions() + ) + const key = toHex(authorizationId) + const transaction = Transaction.fromAtomicBEEF(request.atomicBeef as AtomicBEEF) + const matchingOutputs = transaction.outputs.filter( + output => + output.satoshis !== undefined && + BigInt(output.satoshis) === BigInt(runtime.payee.satoshis) && + toHex(output.lockingScript.toUint8Array()) === + toHex(bytes(request.authorization.body.lockingScript, undefined, 'Authorized script')) + ) + if (matchingOutputs.length !== 1) + throw new LCHError( + 'ERR_LCH_PAYMENT', + 'Accepted transaction does not contain exactly one authorized output' + ) + const txid = transaction.id('hex') + const accepted = this.acceptedTransactions.get(key) + if (accepted !== undefined && accepted !== txid) + throw new LCHError( + 'ERR_LCH_PAYMENT', + 'Payment Authorization was already bound to another accepted transaction' + ) + const existing = this.transactionEvidence.get(key) + if (existing !== undefined) return existing + this.acceptedTransactions.set(key, txid) + const evidence = await this.settlementService.createTransactionEvidence({ + authorizationId, + txid: hex(txid), + state: 'accepted', + policy: LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance, + observedAt: this.now() + }) + this.transactionEvidence.set(key, evidence) + return evidence + } + + private async retrieveDelivery( + request: SignedObject + ): Promise { + const authorizationId = bytes(request.body.authorizationId, 32, 'Retrieval Authorization ID') + const stored = this.storedDeliveries.get(toHex(authorizationId)) + if (stored === undefined) return undefined + await validatePaymentDeliveryRetrieval(request, stored.authorization) + const requestedAt = BigInt(request.body.requestedAt as number | bigint) + const authorizedAt = BigInt(stored.authorization.body.authorizedAt as number | bigint) + const availableUntil = BigInt(stored.acknowledgement.body.availableUntil as number | bigint) + if (requestedAt < authorizedAt || requestedAt >= availableUntil) + throw new Error('Delivery retrieval time is outside the retained window') + return { + authorization: stored.authorization, + delivery: stored.delivery, + deliveryAcknowledgement: stored.acknowledgement + } + } + + async complete(completion: PaymentCompletion): Promise { + const requestId = await validateLicenseRequest(completion.request) + const key = toHex(requestId) + const quoteRecord = this.quotes.get(key) + if (quoteRecord === undefined) throw new Error('Quote is unknown') + equalObject(completion.quote, quoteRecord.quote, 'Quote') + const transaction = Transaction.fromAtomicBEEF(completion.atomicBeef as AtomicBEEF) + const outputIndices = new Set() + const receipts = await this.validateReceiptProofs( + completion, + quoteRecord, + transaction, + requestId, + outputIndices + ) + const authorizedOutputs = await this.validateAuthorizedOutputProofs( + completion, + quoteRecord, + receipts, + outputIndices + ) + if (receipts.size + authorizedOutputs.size !== quoteRecord.demands.size) + throw new Error('A required settlement proof is missing') + const existing = this.licenses.get(key) + if (existing !== undefined) return existing + + const buyer = bytes(completion.request.body.buyer, 33, 'Buyer identity') + const keyGrants = await Promise.all( + [...quoteRecord.asset.protectedAsset.keys.entries()].map(async ([keyIdHex, cek]) => ({ + keyId: hex(keyIdHex), + delivery: LCH_MECHANISMS.brc78Key, + payload: await this.keyDelivery.deliver(toHex(buyer), hex(keyIdHex), cek) + })) + ) + const target = `lch:asset:sha256:${toHex(quoteRecord.asset.assetId)}` + const agreementBytes = new TextEncoder().encode( + JSON.stringify({ + '@context': ['http://www.w3.org/ns/odrl.jsonld'], + '@type': 'Agreement', + uid: 'lch:license:self', + profile: 'https://bsv.brc.dev/apps/0170#odrl-profile', + assignee: `lch:identity:secp256k1:${toHex(buyer)}`, + permission: [ + { target, action: completion.request.body.action }, + { target, action: 'derive' } + ], + prohibition: [{ target, action: 'unwrap' }] + }) + ) + const license = await this.issuer.issueLicense({ + assetId: quoteRecord.asset.assetId, + offerId: quoteRecord.asset.offerId, + requestId, + issuer: this.issuerIdentity, + subject: buyer, + issuedAt: this.now(), + agreement: { + mediaType: 'application/ld+json', + digest: await sha256(agreementBytes), + inline: agreementBytes + }, + selection: selection(completion.request.body.selection), + fulfillments: await Promise.all( + [...quoteRecord.demands].map(async ([demandId, runtime]) => { + const receipt = receipts.get(demandId) + const bundle = authorizedOutputs.get(demandId) + return { + dutyUid: runtime.demand.body.dutyUid as string, + settlementProfile: runtime.demand.body.settlementProfile as string, + ...(receipt === undefined + ? { + authorizationId: await objectId( + 'payment-authorization', + bundle!.authorization.body + ), + transactionEvidenceId: await objectId( + 'transaction-evidence', + bundle!.transactionEvidence.body + ), + deliveryAcknowledgementId: await objectId( + 'payment-delivery-ack', + bundle!.deliveryAcknowledgement.body + ) + } + : { receiptIds: [await objectId('payment-receipt', receipt.body)] }) + } + }) + ), + keyGrants, + encryption: ( + quoteRecord.asset.protectedAsset.asset.representation as Record + ).encryption as never + }) + this.licenses.set(key, license) + return license + } + + private async validateReceiptProofs( + completion: PaymentCompletion, + quoteRecord: QuoteRecord, + transaction: Transaction, + requestId: Uint8Array, + outputIndices: Set + ): Promise> { + const receipts = new Map() + const txid = transaction.id('hex') + for (const receipt of completion.receipts) { + await validatePaymentReceipt(receipt) + const demandId = bytes(receipt.body.demandId, 32, 'Receipt Demand ID') + const demandIdHex = toHex(demandId) + const runtime = quoteRecord.demands.get(demandIdHex) + if (runtime === undefined) throw new Error('Receipt is not required by the Quote') + equal(receipt.body.requestId, requestId, 'Receipt Request ID') + equal(receipt.body.payee, runtime.demand.body.payee, 'Receipt Payee') + if (toHex(bytes(receipt.body.txid, 32, 'Receipt transaction ID')) !== txid) + throw new Error('Receipt transaction does not match Atomic BEEF') + const outputIndex = Number(receipt.body.outputIndex) + uniqueOutputIndex(outputIndex, outputIndices, 'Receipt') + const output = transaction.outputs[outputIndex] + if ( + output?.satoshis === undefined || + BigInt(output.satoshis) !== BigInt(runtime.payee.satoshis) || + BigInt(receipt.body.satoshis as number | bigint) !== BigInt(runtime.payee.satoshis) + ) + throw new Error('Receipt amount does not match the Demand') + outputIndices.add(outputIndex) + if (receipts.has(demandIdHex)) throw new Error('Payment Receipt is duplicated') + receipts.set(demandIdHex, receipt) + } + return receipts + } + + private async validateAuthorizedOutputProofs( + completion: PaymentCompletion, + quoteRecord: QuoteRecord, + receipts: ReadonlyMap, + outputIndices: Set + ): Promise> { + const authorizedOutputs = new Map() + for (const bundle of completion.authorizedOutputs ?? []) { + const demandId = bytes(bundle.authorization.body.demandId, 32, 'Authorization Demand ID') + const demandIdHex = toHex(demandId) + const runtime = quoteRecord.demands.get(demandIdHex) + if (runtime === undefined) throw new Error('Authorized output is not required by the Quote') + if (receipts.has(demandIdHex) || authorizedOutputs.has(demandIdHex)) + throw new Error('Payment Demand has more than one settlement proof') + await validateAuthorizedOutputEvidence( + bundle, + runtime.demand, + completion.atomicBeef, + undefined, + this.validationOptions() + ) + const outputIndex = Number(bundle.delivery.body.outputIndex) + uniqueOutputIndex(outputIndex, outputIndices, 'Authorized output') + outputIndices.add(outputIndex) + authorizedOutputs.set(demandIdHex, bundle) + } + return authorizedOutputs + } + + async recover(requestId: Uint8Array): Promise { + return this.licenses.get(toHex(requestId)) + } + + private payeeByLabel(label: string): PayeeRuntime { + const payee = this.payees.find(candidate => candidate.label === label) + if (payee === undefined) throw new Error(`Unknown Payee: ${label}`) + return payee + } + + private demandFor(delivery: SignedObject): SignedObject { + const demandId = bytes(delivery.body.demandId, 32, 'Delivery Demand ID') + const runtime = this.demandIndex.get(toHex(demandId)) + if (runtime === undefined) throw new Error('Payment Delivery refers to an unknown Demand') + return runtime.demand + } + + private validationOptions(): { allowInsecureLocalOrigins: string[] } { + const origin = new URL(this.acquisitionEndpoint).origin + return { allowInsecureLocalOrigins: isLocalHttp(this.acquisitionEndpoint) ? [origin] : [] } + } + + private requestAsset(request: SignedObject): AssetRecord { + const offerId = bytes(request.body.offerId, 32, 'Request Offer ID') + const asset = this.offers.get(toHex(offerId)) + if (asset === undefined) throw new Error('Offer is unknown') + equal(request.body.assetId, asset.assetId, 'Request Asset ID') + return asset + } + + private validateRequestTerms(request: SignedObject, asset: AssetRecord): void { + equal(request.body.acceptedPolicyDigest, asset.policyDigest, 'Accepted Policy digest') + if (!['play', 'derive'].includes(request.body.action as string)) + throw new Error('Requested action is not offered') + } +} + +function requestEndpoint(value: string): string { + const url = new URL(value) + return `${url.origin}${url.pathname}${url.search}` +} + +export function referenceApiResponse(value: ReferencePublishedAsset): Response { + const body = { + assetId: toHex(value.assetId), + offerId: toHex(value.offerId), + acquisitionEndpoint: value.acquisitionEndpoint, + lchBase64url: toBase64Url(value.lch) + } + return Response.json(body, { headers: { 'cache-control': 'no-store' } }) +} + +function publicAsset(record: AssetRecord): ReferencePublishedAsset { + return { + assetId: record.assetId, + offerId: record.offerId, + lch: record.lch, + acquisitionEndpoint: record.acquisitionEndpoint + } +} + +function isLocalHttp(value: string): boolean { + const url = new URL(value) + return url.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname) +} + +function selection(value: LCHValue | undefined): { type: 'all' } { + if ( + value === null || + value === undefined || + typeof value !== 'object' || + Array.isArray(value) || + value instanceof Uint8Array || + value.type !== 'all' + ) + throw new Error('Reference server currently expects the whole-Asset selection') + return { type: 'all' } +} + +function bytes(value: unknown, length: number | undefined, name: string): Uint8Array { + if ( + !(value instanceof Uint8Array) || + value.length === 0 || + (length !== undefined && value.length !== length) + ) + throw new Error(`${name} is invalid`) + return value +} + +function equal(value: unknown, expected: unknown, name: string): void { + if ( + !(value instanceof Uint8Array) || + !(expected instanceof Uint8Array) || + toHex(value) !== toHex(expected) + ) + throw new Error(`${name} does not match`) +} + +function equalObject(value: SignedObject, expected: SignedObject, name: string): void { + if ( + toHex(encodeDeterministicCbor(value as unknown as LCHValue)) !== + toHex(encodeDeterministicCbor(expected as unknown as LCHValue)) + ) + throw new Error(`${name} does not match`) +} + +function uniqueOutputIndex(value: number, used: ReadonlySet, name: string): void { + if (!Number.isSafeInteger(value) || value < 0 || used.has(value)) + throw new Error(`${name} index is invalid or duplicated`) +} + +function hex(value: string): Uint8Array { + if (!/^[0-9a-f]{64}$/u.test(value)) throw new Error('Key ID is invalid') + return Uint8Array.from(value.match(/../gu)!, pair => Number.parseInt(pair, 16)) +} diff --git a/apps/lch-reference/src/style.css b/apps/lch-reference/src/style.css new file mode 100644 index 000000000..073017aeb --- /dev/null +++ b/apps/lch-reference/src/style.css @@ -0,0 +1,617 @@ +:root { + color: #202428; + background: #f5f6f7; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #f5f6f7; +} + +button, +input { + font: inherit; +} + +button { + border: 1px solid #23282d; + border-radius: 4px; + color: #fff; + background: #23282d; + padding: 10px 14px; + font-weight: 650; + cursor: pointer; +} + +button:hover { + background: #3a4249; +} + +button:disabled { + color: #5f666d; + background: #e5e7e9; + border-color: #d6d9dc; + cursor: not-allowed; +} + +.secondary { + color: #293038; + background: #fff; + border-color: #c9cdd1; +} + +.secondary:hover { + background: #f0f2f3; +} + +.topbar { + min-height: 66px; + border-bottom: 1px solid #d9dcdf; + display: flex; + align-items: center; + gap: 32px; + padding: 10px max(24px, calc((100vw - 1120px) / 2)); + background: #fff; + position: sticky; + top: 0; + z-index: 2; +} + +.topbar strong, +.topbar small { + display: block; +} + +.topbar strong { + font-size: 15px; +} + +.topbar small { + color: #687078; + font-size: 11px; + margin-top: 2px; +} + +.network { + margin-left: 0; + color: #596168; + font: + 11px ui-monospace, + SFMono-Regular, + Menlo, + monospace; + display: flex; + align-items: center; + gap: 7px; +} + +.network i { + width: 7px; + height: 7px; + background: #238636; + border-radius: 50%; +} + +.wallet-mode { + margin-left: auto; + padding: 6px 8px; + border: 1px solid #d6d9dc; + border-radius: 3px; + color: #596168; + background: #f7f8f8; + font: + 10px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +main, +footer { + width: min(1120px, calc(100% - 40px)); + margin-inline: auto; +} + +.intro { + padding: 64px 0 48px; + max-width: 820px; +} + +.kicker, +.step { + color: #596168; + font: + 650 11px ui-monospace, + SFMono-Regular, + Menlo, + monospace; + letter-spacing: 0.08em; +} + +h1 { + font-size: clamp(34px, 5vw, 52px); + letter-spacing: -0.035em; + margin: 12px 0 18px; +} + +h2 { + font-size: 26px; + letter-spacing: -0.02em; + margin: 8px 0 12px; +} + +h3 { + font-size: 15px; + margin: 14px 0 5px; +} + +p { + color: #596168; + line-height: 1.6; +} + +.safety { + margin-top: 24px; + padding: 13px 15px; + border-left: 3px solid #b58105; + background: #fff8df; + color: #594b24; + font-size: 13px; +} + +.panel { + border-top: 1px solid #d4d8db; + padding: 54px 0; +} + +.flow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + padding: 22px 0 42px; +} + +.flow div { + min-height: 78px; + padding: 12px; + border: 1px solid #d4d8db; + border-radius: 4px; + background: #fff; +} + +.flow b, +.flow span { + display: block; +} + +.flow b { + margin-bottom: 7px; + font-size: 12px; +} + +.flow span { + color: #6a7279; + font-size: 11px; + line-height: 1.45; +} + +.fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 20px 0 10px; +} + +.fields label, +.storage { + display: grid; + gap: 7px; + color: #5e666d; + font-size: 11px; +} + +.fields input, +.fields select, +.storage select { + width: 100%; + padding: 9px; + border: 1px solid #c9cdd1; + border-radius: 3px; + color: #252a2f; + background: #fff; +} + +.storage { + margin-bottom: 12px; +} + +.storage span { + color: #7a8289; + font: + 10px/1.5 ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 64px; + align-items: center; +} + +.drop { + position: relative; + border: 1px dashed #aeb4b9; + border-radius: 4px; + padding: 26px; + margin: 24px 0 12px; + text-align: center; + background: #fff; +} + +.drop input { + inset: 0; + opacity: 0; + position: absolute; + cursor: pointer; +} + +.drop label { + font-weight: 650; +} + +.drop span, +.fine { + color: #707880; + font: + 11px/1.6 ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.offline-case { + display: flex; + align-items: flex-start; + gap: 9px; + margin: 14px 0 8px; + color: #4e565d; + font-size: 12px; + line-height: 1.45; +} + +.offline-case input { + margin-top: 2px; +} + +.receipt { + border: 1px solid #d2d6d9; + border-radius: 4px; + background: #fff; + min-height: 290px; + padding: 18px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.receipt .empty { + color: #7a8289; + text-align: center; +} + +.receipt > div:not(.empty) { + display: flex; + justify-content: space-between; + gap: 20px; + border-bottom: 1px solid #eceeef; + padding: 15px 4px; +} + +.receipt span, +.receipt code { + font: + 11px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.receipt span { + color: #6d757c; +} + +.receipt code { + text-align: right; +} + +.settlement { + min-height: 0; + margin-top: 18px; + padding: 10px 14px; +} + +.settlement > div:not(.empty) { + padding: 9px 2px; +} + +.media-stage { + min-height: 330px; + border: 1px solid #cbd0d4; + border-radius: 4px; + background: #e9ecee; + display: grid; + place-content: center; + text-align: center; + position: relative; + overflow: hidden; +} + +.media-stage audio, +.media-stage video { + width: 100%; + max-height: 430px; +} + +.placeholder { + color: #747d84; + font: + 12px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.verified { + position: absolute; + left: 12px; + bottom: 12px; + color: #155b24; + background: #e5f5e8; + border: 1px solid #b7d9bd; + border-radius: 3px; + padding: 7px 9px; + font: + 10px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.price { + display: grid; + gap: 5px; + border-block: 1px solid #d8dcdf; + margin: 24px 0; + padding: 16px 0; +} + +.price span, +.price code { + color: #697178; + font: + 10px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.price strong { + font-size: 22px; +} + +.section-heading { + display: flex; + justify-content: space-between; + align-items: end; + gap: 32px; + margin-bottom: 24px; +} + +.section-heading p { + margin-bottom: 0; +} + +.profile-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.profile-grid article { + min-height: 160px; + border: 1px solid #d4d8db; + border-radius: 4px; + background: #fff; + padding: 16px; +} + +.profile-grid article > span { + color: #555d64; + background: #eceeef; + border-radius: 10px; + padding: 3px 7px; + font: + 9px ui-monospace, + SFMono-Regular, + Menlo, + monospace; + text-transform: uppercase; +} + +.profile-grid article.passed { + border-color: #9bc9a3; +} + +.profile-grid article.passed > span { + color: #155b24; + background: #dff1e2; +} + +.profile-grid code, +.profile-grid li { + color: #697178; + font: + 10px/1.5 ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.profile-grid ul { + padding-left: 18px; + margin-bottom: 0; +} + +pre { + max-height: 320px; + overflow: auto; + border: 1px solid #d3d7da; + border-radius: 4px; + background: #fff; + padding: 16px; + color: #384149; + font: + 10px/1.55 ui-monospace, + SFMono-Regular, + Menlo, + monospace; + white-space: pre-wrap; +} + +#profile-output { + margin-top: 12px; +} + +.edit-controls, +.compose-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.timeline { + display: grid; + grid-template-columns: 130px 1fr; + min-height: 94px; + border: 1px solid #cdd2d5; + border-radius: 4px; + background: #fff; + margin: 16px 0; +} + +.track-label { + border-right: 1px solid #d9dcdf; + padding: 16px; + color: #6e767d; + font: + 10px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.clips { + display: flex; + align-items: center; + gap: 7px; + overflow-x: auto; + padding: 12px; +} + +.clips > span { + color: #92989e; + font: + 11px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.clip { + flex: 0 0 auto; + color: #23313b; + background: #dfe9ef; + border-color: #a9bac6; + font: + 10px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.clip.time-warp { + background: #ebe4f4; + border-color: #c1b1d5; +} + +.clip.reverse { + background: #f0e8d6; + border-color: #cdbd99; +} + +.clip.distortion { + background: #f4dfdf; + border-color: #d2aaaa; +} + +#edit-preview { + width: 100%; + margin: 12px 0; +} + +footer { + padding: 28px 0 40px; + border-top: 1px solid #d4d8db; + display: flex; + justify-content: space-between; + color: #70787f; + font: + 10px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +@media (max-width: 780px) { + .split, + .profile-grid, + .flow, + .fields { + grid-template-columns: 1fr; + } + + .split { + gap: 28px; + } + + .section-heading, + footer { + align-items: flex-start; + flex-direction: column; + } + + .timeline { + grid-template-columns: 1fr; + } + + .track-label { + border-right: 0; + border-bottom: 1px solid #d9dcdf; + } +} diff --git a/apps/lch-reference/test/demo.test.ts b/apps/lch-reference/test/demo.test.ts new file mode 100644 index 000000000..28192a94c --- /dev/null +++ b/apps/lch-reference/test/demo.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + EDITORIAL_CASES, + buildEditorialComposition, + createToneWav, + runCoreProfileChecks, + transformToneWav, + type EditorialPlacement +} from '../src/demo.js' + +describe('reference media fixture', () => { + it('creates a valid PCM WAV envelope', () => { + const bytes = createToneWav(1) + expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe('RIFF') + expect(new TextDecoder().decode(bytes.slice(8, 12))).toBe('WAVE') + expect(bytes.length).toBeGreaterThan(44) + }) + + it.each([ + { id: 1, label: 'half speed', kind: 'time-warp', rateNumerator: 1, rateDenominator: 2 }, + { id: 2, label: 'double speed', kind: 'time-warp', rateNumerator: 2, rateDenominator: 1 }, + { id: 3, label: 'reverse', kind: 'reverse' }, + { id: 4, label: 'distorted', kind: 'distortion', distortionAmount: 4 } + ] as EditorialPlacement[])('renders the $label editorial edge case', placement => { + const source = createToneWav(1) + const transformed = transformToneWav(source, placement) + expect(new TextDecoder().decode(transformed.slice(0, 4))).toBe('RIFF') + if (placement.rateNumerator === 1) expect(transformed.length).toBeGreaterThan(source.length) + if (placement.rateNumerator === 2) expect(transformed.length).toBeLessThan(source.length) + if (placement.kind === 'reverse' || placement.kind === 'distortion') { + expect(transformed).not.toEqual(source) + } + }) + + it('rejects non-positive or non-integer playback-rate ratios', () => { + const source = createToneWav(1) + expect(() => + transformToneWav(source, { + id: 1, + label: 'zero denominator', + kind: 'time-warp', + rateNumerator: 1, + rateDenominator: 0 + }) + ).toThrow(/positive safe integers/u) + expect(() => + transformToneWav(source, { + id: 2, + label: 'fractional numerator', + kind: 'time-warp', + rateNumerator: 0.5, + rateDenominator: 1 + }) + ).toThrow(/positive safe integers/u) + }) + + it('records repeats and transformed edits as distinct whole placements', async () => { + const placements: EditorialPlacement[] = [ + { id: 1, label: 'repeat a', kind: 'identity' }, + { id: 2, label: 'repeat b', kind: 'identity' }, + ...EDITORIAL_CASES.slice(1).map((item, index) => ({ id: index + 3, ...item })) + ] + const record = await buildEditorialComposition( + new Uint8Array(32).fill(1), + new Uint8Array(32).fill(2), + placements + ) + expect(record.ingredients).toHaveLength(placements.length) + expect(new Set(record.ingredients.map(item => item.c2paIngredient.url)).size).toBe( + placements.length + ) + expect(record.ingredients.every(item => item.derivedSelection.type === 'all')).toBe(true) + }) + + it('exercises every initial profile and its reference boundary cases', async () => { + const result = await runCoreProfileChecks( + new Uint8Array(32).fill(3), + new Uint8Array(32).fill(4) + ) + expect(result).toHaveLength(6) + expect(result.every(item => item.status === 'pass')).toBe(true) + }) +}) diff --git a/apps/lch-reference/test/referenceFlow.test.ts b/apps/lch-reference/test/referenceFlow.test.ts new file mode 100644 index 000000000..80a77feb9 --- /dev/null +++ b/apps/lch-reference/test/referenceFlow.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it } from 'vitest' +import { LockingScript, Transaction } from '@bsv/sdk' +import { + LCHHttpAcquisitionClient, + LCH_SETTLEMENT_PROFILES, + WalletBRC77Signer, + signObject +} from '@bsv/lch' +import { createFixtureWallet } from '../src/fixtureWallet.js' +import { ReferenceLCHClient } from '../src/referenceClient.js' +import { ReferenceLCHServer } from '../src/referenceServer.js' + +describe('reference creator, server, wallet, and player flow', () => { + it('preflights without spending, then pays every wallet and decrypts the exact asset', async () => { + const issuer = createFixtureWallet(81) + const recordingPayee = createFixtureWallet(82) + const compositionPayee = createFixtureWallet(83) + const buyer = createFixtureWallet(84) + const baseUrl = 'http://127.0.0.1:4173' + const server = await ReferenceLCHServer.create({ + issuerWallet: issuer, + publicBaseUrl: baseUrl, + payees: [ + { + wallet: recordingPayee, + satoshis: 7, + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller' + }, + { + wallet: compositionPayee, + satoshis: 5, + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] + }) + const plaintext = new TextEncoder().encode('reference media bytes') + const published = await server.publish({ + bytes: plaintext, + mediaType: 'application/octet-stream', + name: 'reference.bin' + }) + const endpointPolicy = { + allowLocalOrigins: [baseUrl], + connect: async (url: URL, init: RequestInit) => server.http.handle(new Request(url, init)) + } + const client = new ReferenceLCHClient(buyer, server.content, { endpointPolicy }) + + const plan = await client.prepare(published.lch) + expect(plan.totalSatoshis).toBe(12n) + expect(plan.readiness).toHaveLength(2) + expect(plan.demands.map(demand => demand.body.endpoint)).toEqual( + server.payeeEndpoints.map(item => item.endpoint) + ) + const http = new LCHHttpAcquisitionClient({ + endpointPolicy: { + allowLocalOrigins: [baseUrl], + connect: async (url, init) => server.http.handle(new Request(url, init)) + } + }) + await expect( + http.preflightDemand(server.payeeEndpoints[1]!.endpoint, plan.demands[0]!) + ).rejects.toMatchObject({ code: 'ERR_LCH_DELIVERY' }) + expect(recordingPayee.receivedSatoshis).toBe(0) + expect(compositionPayee.receivedSatoshis).toBe(0) + + const result = await client.acquire(plan) + expect(result.plaintext).toEqual(plaintext) + expect(result.receipts).toHaveLength(2) + expect(result.transactionId).toMatch(/^[0-9a-f]{64}$/u) + expect(result.recovered).toBe(true) + expect(recordingPayee.receivedSatoshis).toBe(7) + expect(compositionPayee.receivedSatoshis).toBe(5) + expect(recordingPayee.internalizedActions).toHaveLength(1) + expect(compositionPayee.internalizedActions).toHaveLength(1) + }) + + it('refuses to create a wallet transaction at the exact Quote expiry boundary', async () => { + let now = 1_000n + const recordingPayee = createFixtureWallet(92) + const compositionPayee = createFixtureWallet(93) + const server = await ReferenceLCHServer.create({ + issuerWallet: createFixtureWallet(91), + publicBaseUrl: 'https://expiry.test', + now: () => now, + payees: [ + { + wallet: recordingPayee, + satoshis: 7, + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller' + }, + { + wallet: compositionPayee, + satoshis: 5, + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] + }) + const published = await server.publish({ + bytes: new TextEncoder().encode('expiry fixture'), + mediaType: 'text/plain', + name: 'expiry.txt' + }) + const client = new ReferenceLCHClient(createFixtureWallet(94), server.content, { + now: () => now, + endpointPolicy: { + allowLocalOrigins: ['https://expiry.test'], + connect: async (url, init) => server.http.handle(new Request(url, init)) + } + }) + const plan = await client.prepare(published.lch) + now = plan.expiresAt + await expect(client.acquire(plan)).rejects.toThrow(/expired before readiness refresh/u) + expect(recordingPayee.receivedSatoshis).toBe(0) + expect(compositionPayee.receivedSatoshis).toBe(0) + }) + + it('retries an ambiguous Payee delivery with the retained transaction instead of paying again', async () => { + const recordingPayee = createFixtureWallet(112) + const compositionPayee = createFixtureWallet(113) + const buyer = createFixtureWallet(114) + const baseUrl = 'https://recovery.test' + const server = await ReferenceLCHServer.create({ + issuerWallet: createFixtureWallet(111), + publicBaseUrl: baseUrl, + payees: [ + { + wallet: recordingPayee, + satoshis: 7, + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller' + }, + { + wallet: compositionPayee, + satoshis: 5, + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] + }) + const published = await server.publish({ + bytes: new TextEncoder().encode('recovery fixture'), + mediaType: 'text/plain', + name: 'recovery.txt' + }) + let deliveryAttempts = 0 + const client = new ReferenceLCHClient(buyer, server.content, { + endpointPolicy: { + allowLocalOrigins: [baseUrl], + connect: async (url, init) => { + if (new Headers(init.headers).get('content-type')?.includes('type=payment-delivery')) { + deliveryAttempts += 1 + if (deliveryAttempts === 2) return new Response(null, { status: 503 }) + } + return server.http.handle(new Request(url, init)) + } + } + }) + const plan = await client.prepare(published.lch) + await expect(client.acquire(plan)).rejects.toMatchObject({ code: 'ERR_LCH_DELIVERY' }) + expect(client.hasPendingPayment()).toBe(true) + expect(client.pendingPayment()).toMatchObject({ + transactionState: 'finalized', + settlementState: 'pending-settlement-proofs', + receipts: 1, + authorizedOutputs: 0, + requiredProofs: 2, + recoveryUntil: plan.recoveryUntil + }) + expect(buyer.createdActions).toBe(1) + expect(recordingPayee.receivedSatoshis).toBe(7) + expect(compositionPayee.receivedSatoshis).toBe(0) + + await expect(client.acquire(plan)).resolves.toMatchObject({ recovered: true }) + expect(client.hasPendingPayment()).toBe(false) + expect(buyer.createdActions).toBe(1) + expect(recordingPayee.receivedSatoshis).toBe(7) + expect(compositionPayee.receivedSatoshis).toBe(5) + }) + + it('licenses an accepted authorized output while its Payee is offline, then recovers it later', async () => { + const recordingPayee = createFixtureWallet(122) + const compositionPayee = createFixtureWallet(123) + const buyer = createFixtureWallet(124) + const baseUrl = 'https://authorized-output.test' + const server = await ReferenceLCHServer.create({ + issuerWallet: createFixtureWallet(121), + publicBaseUrl: baseUrl, + payees: [ + { + wallet: recordingPayee, + satoshis: 7, + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller', + settlementProfile: LCH_SETTLEMENT_PROFILES.authorizedOutput + }, + { + wallet: compositionPayee, + satoshis: 5, + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] + }) + const plaintext = new TextEncoder().encode('offline drummer fixture') + const published = await server.publish({ + bytes: plaintext, + mediaType: 'audio/wav', + name: 'offline.wav' + }) + const endpointPolicy = { + allowLocalOrigins: [baseUrl], + connect: async (url: URL, init: RequestInit) => server.http.handle(new Request(url, init)) + } + const client = new ReferenceLCHClient(buyer, server.content, { endpointPolicy }) + const plan = await client.prepare(published.lch) + expect(plan.authorizations).toHaveLength(1) + server.setPayeeOfflineAfterNextReadiness('recording controller') + + const result = await client.acquire(plan) + expect(result.plaintext).toEqual(plaintext) + expect(result.receipts).toHaveLength(1) + expect(result.authorizedOutputs).toHaveLength(1) + expect(result.transactionState).toBe('accepted') + expect(recordingPayee.receivedSatoshis).toBe(0) + expect(compositionPayee.receivedSatoshis).toBe(5) + expect(buyer.createdActions).toBe(1) + + const authorized = result.authorizedOutputs[0]! + const deliveryClient = new LCHHttpAcquisitionClient({ endpointPolicy }) + await expect( + Promise.all([ + deliveryClient.storeDelivery( + server.deliveryEndpoint, + authorized.authorization, + authorized.delivery + ), + deliveryClient.storeDelivery( + server.deliveryEndpoint, + authorized.authorization, + authorized.delivery + ) + ]) + ).resolves.toEqual([authorized.deliveryAcknowledgement, authorized.deliveryAcknowledgement]) + const conflictingDelivery = await signObject( + 'payment-delivery', + { + ...authorized.delivery.body, + outputIndex: Number(authorized.delivery.body.outputIndex) === 0 ? 1 : 0 + }, + await WalletBRC77Signer.create({ wallet: buyer }) + ) + await expect( + deliveryClient.storeDelivery( + server.deliveryEndpoint, + authorized.authorization, + conflictingDelivery + ) + ).rejects.toMatchObject({ code: 'ERR_LCH_DELIVERY' }) + + server.setPayeeOnline('recording controller', true) + await expect(server.recoverStoredPayments('recording controller')).resolves.toHaveLength(1) + expect(recordingPayee.receivedSatoshis).toBe(7) + expect(recordingPayee.internalizedActions).toHaveLength(1) + await expect(server.recoverStoredPayments('recording controller')).resolves.toHaveLength(1) + expect(recordingPayee.receivedSatoshis).toBe(7) + expect(recordingPayee.internalizedActions).toHaveLength(1) + }) + + it('keeps one finalized payment pending until the authorized Delivery provider returns', async () => { + const recordingPayee = createFixtureWallet(132) + const compositionPayee = createFixtureWallet(133) + const buyer = createFixtureWallet(134) + const baseUrl = 'https://availability-recovery.test' + const server = await ReferenceLCHServer.create({ + issuerWallet: createFixtureWallet(131), + publicBaseUrl: baseUrl, + payees: [ + { + wallet: recordingPayee, + satoshis: 7, + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller', + settlementProfile: LCH_SETTLEMENT_PROFILES.authorizedOutput + }, + { + wallet: compositionPayee, + satoshis: 5, + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] + }) + const published = await server.publish({ + bytes: new TextEncoder().encode('availability fixture'), + mediaType: 'text/plain', + name: 'availability.txt' + }) + const client = new ReferenceLCHClient(buyer, server.content, { + endpointPolicy: { + allowLocalOrigins: [baseUrl], + connect: async (url, init) => server.http.handle(new Request(url, init)) + } + }) + const plan = await client.prepare(published.lch) + server.setPayeeOfflineAfterNextReadiness('recording controller') + server.setAvailabilityProviderOnline(false) + await expect(client.acquire(plan)).rejects.toMatchObject({ code: 'ERR_LCH_DELIVERY' }) + expect(buyer.createdActions).toBe(1) + expect(client.pendingPayment()).toMatchObject({ + transactionState: 'finalized', + receipts: 0, + authorizedOutputs: 0, + requiredProofs: 2 + }) + + server.setAvailabilityProviderOnline(true) + await expect(client.acquire(plan)).resolves.toMatchObject({ + receipts: expect.arrayContaining([expect.any(Object)]), + authorizedOutputs: expect.arrayContaining([expect.any(Object)]) + }) + expect(buyer.createdActions).toBe(1) + }) + + it('keeps receipt-complete settlement pending when an offline Payee has not delegated fallback', async () => { + const recordingPayee = createFixtureWallet(142) + const compositionPayee = createFixtureWallet(143) + const buyer = createFixtureWallet(144) + const baseUrl = 'https://strict-offline.test' + const server = await ReferenceLCHServer.create({ + issuerWallet: createFixtureWallet(141), + publicBaseUrl: baseUrl, + payees: [ + { + wallet: recordingPayee, + satoshis: 7, + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller' + }, + { + wallet: compositionPayee, + satoshis: 5, + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] + }) + const published = await server.publish({ + bytes: new TextEncoder().encode('strict fixture'), + mediaType: 'text/plain', + name: 'strict.txt' + }) + const client = new ReferenceLCHClient(buyer, server.content, { + endpointPolicy: { + allowLocalOrigins: [baseUrl], + connect: async (url, init) => server.http.handle(new Request(url, init)) + } + }) + const plan = await client.prepare(published.lch) + expect(plan.authorizations).toHaveLength(0) + server.setPayeeOfflineAfterNextReadiness('recording controller') + await expect(client.acquire(plan)).rejects.toMatchObject({ code: 'ERR_LCH_DELIVERY' }) + expect(client.hasPendingPayment()).toBe(true) + expect(buyer.createdActions).toBe(1) + await expect(server.recover(plan.requestId)).resolves.toBeUndefined() + }) + + it('rejects the wrong output and conflicting accepted transactions for one Authorization', async () => { + const baseUrl = 'https://transaction-evidence.test' + const server = await ReferenceLCHServer.create({ + issuerWallet: createFixtureWallet(151), + publicBaseUrl: baseUrl, + payees: [ + { + wallet: createFixtureWallet(152), + satoshis: 7, + dutyUid: 'urn:lch:duty:recording', + interest: 'recording', + label: 'recording controller', + settlementProfile: LCH_SETTLEMENT_PROFILES.authorizedOutput + }, + { + wallet: createFixtureWallet(153), + satoshis: 5, + dutyUid: 'urn:lch:duty:composition', + interest: 'composition', + label: 'composition controller' + } + ] + }) + const published = await server.publish({ + bytes: new TextEncoder().encode('evidence fixture'), + mediaType: 'text/plain', + name: 'evidence.txt' + }) + const endpointPolicy = { + allowLocalOrigins: [baseUrl], + connect: async (url: URL, init: RequestInit) => server.http.handle(new Request(url, init)) + } + const plan = await new ReferenceLCHClient(createFixtureWallet(154), server.content, { + endpointPolicy + }).prepare(published.lch) + const authorization = plan.authorizations[0]! + const http = new LCHHttpAcquisitionClient({ endpointPolicy }) + const wrong = new Transaction( + 1, + [], + [{ satoshis: 7, lockingScript: LockingScript.fromHex('51') }] + ) + await expect( + http.attestTransaction( + server.evidenceEndpoint, + authorization, + Uint8Array.from(wrong.toAtomicBEEF(true)) + ) + ).rejects.toMatchObject({ code: 'ERR_LCH_PAYMENT' }) + + const output = { + satoshis: 7, + lockingScript: LockingScript.fromHex( + Array.from(authorization.body.lockingScript as Uint8Array, value => + value.toString(16).padStart(2, '0') + ).join('') + ) + } + const first = new Transaction(1, [], [output], 0) + const conflicting = new Transaction(1, [], [output], 1) + await expect( + http.attestTransaction( + server.evidenceEndpoint, + authorization, + Uint8Array.from(first.toAtomicBEEF(true)) + ) + ).resolves.toMatchObject({ body: { state: 'accepted' } }) + await expect( + http.attestTransaction( + server.evidenceEndpoint, + authorization, + Uint8Array.from(conflicting.toAtomicBEEF(true)) + ) + ).rejects.toMatchObject({ code: 'ERR_LCH_PAYMENT' }) + }) +}) diff --git a/apps/lch-reference/tsconfig.json b/apps/lch-reference/tsconfig.json new file mode 100644 index 000000000..8efbb53b7 --- /dev/null +++ b/apps/lch-reference/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../config/typescript/dual-runtime.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "types": ["vite/client", "node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/docs/_schemas/page.schema.json b/docs/_schemas/page.schema.json index 2aa2082ba..8270c62f9 100644 --- a/docs/_schemas/page.schema.json +++ b/docs/_schemas/page.schema.json @@ -7,7 +7,7 @@ "id": { "type": "string", "description": "Stable slug, never changes" }, "title": { "type": "string" }, "kind": { "type": "string", "enum": ["package", "infra", "spec", "guide", "conformance", "reference", "meta"] }, - "domain": { "type": ["string", "null"], "enum": ["sdk", "wallet", "network", "overlays", "messaging", "middleware", "helpers", "infra", null] }, + "domain": { "type": ["string", "null"], "enum": ["sdk", "wallet", "network", "content", "overlays", "messaging", "middleware", "helpers", "infra", null] }, "version": { "type": "string" }, "source_repo": { "type": "string" }, "source_commit": { "type": "string" }, diff --git a/docs/guides/chirp-lch-production.md b/docs/guides/chirp-lch-production.md new file mode 100644 index 000000000..8534274fb --- /dev/null +++ b/docs/guides/chirp-lch-production.md @@ -0,0 +1,506 @@ +--- +id: guide-chirp-lch-production +title: 'Build Production CHIRP and LCH Applications' +kind: guide +domain: content +version: '1.0.0' +last_updated: '2026-08-28' +last_verified: '2026-08-28' +review_cadence_days: 30 +status: experimental +tags: [guide, chirp, lch, uhrp, brc-167, brc-170, storage, payments, wallet] +--- + +# Build Production CHIRP and LCH Applications + +> Publish large verified ciphertext with CHIRP, describe and license it with +> LCH, pay every rights controller directly, and recover safely when a service +> becomes unavailable after transaction creation. + +**Time:** ~45 minutes + +**Prerequisites:** TypeScript, a BRC-100 wallet, one or more CHIRP-capable UHRP +hosts, and HTTPS endpoints for the LCH roles you operate. + +The normative standards are [BRC-167 CHIRP](https://bsv.brc.dev/overlays/0167) +and [BRC-170 LCH](https://bsv.brc.dev/apps/0170). This guide explains the TS +Stack reference implementation and a production architecture. The standards +remain authoritative where behavior differs. + +## Choose The Layers + +CHIRP and LCH are independent. Use either one alone or compose them. + +| Need | Use | Result | +| ------------------------------------------------------------------------- | --------------- | ---------------------------------------------------------------------------------- | +| Small, indivisible public bytes addressed by a digest | UHRP | One `uhrp:` object | +| Large or range-read bytes with progressive upload and resilient retrieval | CHIRP over UHRP | One `chirp:` root plus verified Merkle objects | +| Encrypted content, portable rights, payment, key delivery, or composition | LCH | One `.lch` header, embedded or detached ciphertext, and signed acquisition objects | +| Large licensed media | LCH + CHIRP | An LCH representation whose encrypted ciphertext is stored at a `chirp:` locator | + +The important boundary is simple: + +- CHIRP proves that retrieved bytes match a root and supports bounded, + interleaved, range-aware access. It does not prove authorship, grant rights, + or collect payment. +- LCH authenticates the Asset, encrypts its representation, expresses rights + and usage, coordinates payment evidence, delivers keys, and records + composition. It does not require a particular content host. +- UHRP discovery remains the way CHIRP finds complete hosts. Existing UHRP + identifiers, upload/download APIs, advertisements, and routes are unchanged. + +## Install + +```bash +npm install @bsv/lch @bsv/chirp @bsv/sdk +``` + +Both packages support browsers and Node.js. A browser should use its wallet and +normal network boundary. A server should also configure DNS resolution, +connection address pinning, request limits, and credential scoping. + +## Public API By Role + +| Role | Primary APIs | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Canonical CHIRP construction | `CHIRPBuilder`, byte-source adapters, codecs, and closure validation | +| Authenticated CHIRP publication | `CHIRPUploader`, `CHIRPUploadCheckpoint`, and the `chirp publish` CLI | +| CHIRP retrieval | `CHIRPDownloader.stream()`, `download()`, object caches, and the `chirp retrieve/verify` CLI | +| LCH creator and issuer | `LCHPublisher`, `LCHIssuer`, `LCHQuoteIssuer`, `WalletBRC77Signer`, and `WalletBRC78KeyDelivery` | +| LCH buyer and player | `LCHReader`, `LCHMultipayBuyer`, `LCHHttpAcquisitionClient`, and `IndexedDBLicenseStore` | +| Independent Payee | `LCHPayee`, `WalletPaymentReceiver`, and a durable `PaymentLedger` implementation | +| Authorized-output providers | `WalletAuthorizedOutputPayee`, `LCHSettlementService`, and durable Authorization/Delivery stores | +| Wire transport | `LCHHttpServer` for Fetch-compatible handlers or an application `LCHAcquisitionTransport` | +| Policy, authority, and composition | `parsePinnedPolicy`, `validateAuthorityChain`, `LCHComposer`, `activeIngredients`, and `walkComposition` | +| Storage bridge | `CHIRPContentSink`, `UHRPContentSink`, `UniversalContentSource`, or application `ContentSink`/`ContentSource` adapters | + +Prefer the high-level classes for workflows and the exported validators at +trust boundaries. Signed objects remain portable; a server framework, +database, catalogue, wallet substrate, and media decoder are application +choices. + +## Publish LCH Ciphertext Through CHIRP + +`CHIRPContentSink` adapts a `CHIRPUploader` to LCH's storage boundary for the +simple path. CHIRP receives only ciphertext; plaintext and content-encryption +keys stay with the creator/issuer path. + +```typescript +import { CHIRPUploader } from '@bsv/chirp' +import { CHIRPContentSink, LCHPublisher, WalletBRC77Signer } from '@bsv/lch' + +const signer = await WalletBRC77Signer.create({ wallet: issuerWallet }) +const chirpUploader = new CHIRPUploader({ + wallet: issuerWallet, + storageURLs: ['https://storage-a.example', 'https://storage-b.example'], + resilienceLevel: 2 +}) + +const ciphertextSink = new CHIRPContentSink(chirpUploader, 2_592_000, 'application/octet-stream') + +const publisher = new LCHPublisher(signer) +const protectedAsset = await publisher.protect(plaintext, { + name: 'performance.wav', + mediaType: 'audio/wav', + segmentSize: 1_048_576, + keyPeriodSegments: 16, + rights: [ + { + interest: 'sound-recording', + holder: { name: 'Performer' }, + controller: signer.identityKey + } + ], + sink: ciphertextSink +}) +``` + +Create the signed Offer only after `protectedAsset.assetId` exists. Then publish +the detached LCH by passing `false` as the third `publish` argument. The Asset +Body commits to ciphertext length and digest as well as the `chirp:` locator, +so the LCH reader revalidates the complete resolved ciphertext independently of +CHIRP's object and root checks. + +Choose the LCH encryption segment size for license and playback behavior, not +to imitate CHIRP chunks. BRC-170 recommends alignment where practical, but the +two formats retain independent identifiers and validation. + +The built-in sink intentionally has a small interface. When publication must +survive restart, provide an application `ContentSink` that calls +`CHIRPUploader.publish()` with `resume` and an `onCheckpoint` callback, and +encrypt the checkpoint store because it contains host session capabilities: + +```typescript +const resumableCiphertextSink = { + async put(ciphertext: Uint8Array): Promise { + const result = await chirpUploader.publish({ + source: ciphertext, + logicalLength: ciphertext.length, + retentionSeconds: 2_592_000, + mediaType: 'application/octet-stream', + resume: await checkpointStore.get(uploadKey), + onCheckpoint: checkpoint => checkpointStore.put(uploadKey, checkpoint) + }) + await checkpointStore.delete(uploadKey) + return [result.chirpURL] + } +} +``` + +## Read A Detached LCH + +`UniversalContentSource` dispatches `chirp:`, `uhrp:`, and bounded HTTPS +locators. The LCH reader validates the header signer, Asset ID, ciphertext +length and digest, authenticated encryption segments, and full-plaintext digest +when the complete selection is decrypted. + +```typescript +import { CHIRPDownloader } from '@bsv/chirp' +import { IndexedDBLicenseStore, LCHReader, UniversalContentSource } from '@bsv/lch' + +const source = new UniversalContentSource({ + chirp: new CHIRPDownloader({ + concurrency: 4, + urlPolicy: chirpUrlPolicy + }), + maximumBytes: 512 * 1024 * 1024, + endpointPolicy +}) +const licenseStore = new IndexedDBLicenseStore() +const reader = new LCHReader(source, licenseStore) + +const inspected = await reader.inspect(lchBytes) +// After validating and storing a License, unwrap its BRC-78 key grants. +const plaintext = await reader.decrypt(inspected, contentKeys, licensedSelection) +``` + +The current `LCHPublisher.protect()` and `LCHReader.resolve()/decrypt()` +reference path accepts bounded `Uint8Array` values and assembles the selected +representation in memory. Set `maximumBytes` to an application limit and use +this path only for assets that fit it. `CHIRPDownloader.stream()` supports +verified range delivery, but applications must not expose those ciphertext +ranges as LCH plaintext until a segment-aware streaming adapter authenticates +the complete LCH encryption records and licensed selection. That streaming LCH +adapter is not part of the 0.1 API. + +`endpointPolicy` governs direct HTTPS locators and LCH role endpoints; +`CHIRPDownloader.urlPolicy` separately governs the hosts returned by UHRP +resolution. Server applications must constrain both. Supply +`authorizeHeaderSigner` to `LCHReader` only when an application permits a +delegated header signer in addition to the declared rights controllers. + +Each released CHIRP blob is hash-verified, but a complete CHIRP stream can only +verify the root `contentHash` at termination. Buffer atomically when early +consumption is unsafe. Never pass unverified or unauthenticated bytes to a +decoder merely because the declared `mediaType` looks familiar. + +## Acquisition Has One Irreversible Boundary + +Inspecting an LCH, preflighting, and obtaining a Quote do not spend money. +`LCHMultipayBuyer.createPayment()` is the explicit transaction-creation +boundary. The buyer must show the signed terms, action, selection, total, split, +Payees, and settlement profiles before calling it. + +```typescript +import { LCHMultipayBuyer, toHex, type AuthorizedOutputEvidence, type SignedObject } from '@bsv/lch' + +const buyer = await LCHMultipayBuyer.create(buyerWallet, { endpointPolicy }) +const request = await buyer.createRequest({ + offerId, + assetId, + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest, + createdAt: BigInt(Math.floor(Date.now() / 1000)) +}) +const plan = await buyer.quote(acquisitionEndpoint, request, issuerIdentity) + +// Display and confirm plan.totalSatoshis and every signed Demand here. +const freshPlan = await buyer.refreshReadiness(plan) +const funded = await buyer.createPayment(freshPlan) + +// This durable write must finish before network fan-out. +await recoveryStore.put(funded) + +const receipts: SignedObject[] = [] +const authorizedOutputs: AuthorizedOutputEvidence[] = [] +for (const delivery of funded.deliveries) { + const proof = await buyer.settleDelivery(funded, delivery) + await recoveryStore.addProof(funded.plan.requestId, proof) + if (proof.type === 'receipt') receipts.push(proof.receipt) + else authorizedOutputs.push(proof.evidence) +} + +const license = await buyer.complete(funded, receipts, authorizedOutputs) +await licenseStore.put({ + assetId: toHex(assetId), + offerId: toHex(offerId), + license, + storedAt: BigInt(Math.floor(Date.now() / 1000)) +}) +await recoveryStore.complete(funded.plan.requestId) +``` + +`createPayment()` returns finalized transaction bytes. **Finalized** means the +wallet produced a signed Atomic BEEF. It does not mean a processor accepted the +transaction, the network broadcast it, or a block mined it. Persist the exact +result and signed Deliveries before contacting any Payee or provider. + +If delivery or completion times out, recover or resume using the same funded +transaction. Do not automatically create a replacement purchase: the first +transaction may already pay every output even though the response was lost. + +## Where The Money Goes + +Each signed Payment Demand names one Payee identity, amount, BRC-29 derivation +prefix, endpoint, and settlement profile. The buyer wallet creates one exact +output for each Demand. A `WalletPaymentReceiver` at that Payee's endpoint: + +1. verifies the buyer's signed Delivery and the Demand binding; +2. derives and matches the exact BRC-29 locking script and amount; +3. atomically claims the Demand in a durable `PaymentLedger`; +4. invokes that Payee wallet's BRC-100 `internalizeAction`; and +5. returns the Payee's signed Receipt. + +The issuer receives money only when an issued Demand explicitly names the +issuer as a Payee. A drummer, composer, label, publisher, or other controller +can each operate a separate identity, wallet, endpoint, ledger, and hosting +provider. The Quote coordinates their signed Demands; it does not make them +custodial subaccounts of the issuer. + +## Choose A Settlement Profile + +| Profile | License can issue when | Best fit | Tradeoff | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `receipt-complete-v1` | Every Payee wallet has internalized its output and signed a Receipt | Smallest trust boundary and strongest direct acknowledgement | An offline Payee keeps the purchase pending | +| `authorized-output-v1` | Direct delivery succeeded, or the Payee's exact pre-authorization, accepted-transaction evidence, and durable Delivery acknowledgement all verify | A Payee wants buyers to recover after that Payee goes offline | More destination linkage and provider dependence; keys may release before Payee internalization or mining | + +Authorized-output is selected by the Payee, before transaction creation. It is +not an issuer or buyer override. The buyer independently derives the authorized +locking script before asking its wallet to create the transaction. The fallback +still requires one profile-valid proof for every Demand. Silence, broadcast +submission, an unsigned upload response, or insufficient retention is never a +settlement proof. + +If a Payee did not authorize the fallback, its unavailability leaves the +existing transaction pending. That is intentional. Applications should expose +pending status, retries, and recovery—not a second purchase button. + +## Initial LCH Usage Profiles + +An application must advertise the exact profiles and mechanisms it implements; +a bare “BRC-170 compatible” label is insufficient. + +| Profile | Portable core behavior | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `fixed-render-v1` | Fixed-price `play`, `display`, `read`, `execute`, or `render`; the Agreement separately controls offline use, copying, and export | +| `metered-range-v1` | Exact Selection and Quote, independently releasable key periods, and only the segment coverage intersecting the License | +| `metered-event-v1` | A counted page, play, open, render, inference, or other event; an existing reusable entitlement must prevent a duplicate charge | +| `rental-v1` | Explicit date, elapsed, or metered-time constraints plus connectivity and enforcement class; “rental” alone never implies online-only behavior | +| `compose-v1` | `aggregate`, `extract`, or `derive` with C2PA, a Composition Record, applicable downstream duties, and multilateral settlement when required | +| `training-v1` | Declared `train` permission and constraints without implying display, redistribution, source ownership, or an automatic composition claim | + +The reference workbench runs all six profiles, both settlement profiles, +repeated placements, half- and double-speed time warp, reversal, distortion, +offline Payee recovery, provider outage, duplicate delivery, and conflicting +transaction cases. Use those fixtures as interoperability cases, then add the +limits and media formats of the downstream application. + +## Deployment Topologies + +The reference application collapses all roles into one process so every wire +object and edge case can be inspected. A production deployment can separate +every arrow: + +```mermaid +flowchart LR + C[Creator] --> I[Issuer / catalogue] + I --> H1[CHIRP host A] + I --> H2[CHIRP host B] + B[Buyer + BRC-100 wallet] --> I + I -->|Quote with signed Demands| B + B --> P1[Payee A endpoint + ledger + wallet] + B --> P2[Payee B endpoint + ledger + wallet] + B --> E[Accepted-transaction provider] + B --> D[Durable Delivery provider] + P1 -->|late authenticated retrieval| D + B -->|proofs, completion, recovery| I +``` + +There are three common shapes: + +1. **Collapsed interoperability node.** One process and fixture wallets. Use it + for tests, demonstrations, profile exploration, and conformance debugging. +2. **Single-vendor durable service.** Separate durable tables and credentials + per role, even if one operator owns the processes. Replace all in-memory + stores and fixture wallets. +3. **Federated rights network.** Every Payee runs or delegates its endpoint, + ledger, and wallet. Evidence, Delivery, issuer, and content-host services may + have different operators and origins. + +The executable topology, wallet-module contract, route map, container command, +and separation guidance live in the +[LCH reference deployment](https://github.com/bsv-blockchain/ts-stack/blob/main/apps/lch-reference/DEPLOYMENT.md). + +## Durable State And Ownership + +| Role | State that must survive restart | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Creator/issuer | Asset and Offer objects, wrapped CEKs, representation metadata, policy bytes, Requests, Quotes, completion state, Licenses, and recovery indexes | +| CHIRP publisher | Upload checkpoint and host session capabilities until commit; root URL and retention result afterward | +| CHIRP host | Every object in each advertised closure, root metadata, retention/renewal state, and advertisement state | +| Buyer/player | Request, plan, finalized Atomic BEEF, signed Deliveries, every partial proof, License, unwrapped keys, and recovery deadline | +| Each Payee | Demand, readiness and Authorization state, atomic Demand-to-transaction claim, Receipt, and receiving-wallet state | +| Evidence provider | Atomic Authorization-to-transaction claim plus signed policy result | +| Delivery provider | Exact Authorization and signed Delivery bytes through `availableUntil`, plus retrieval audit state | + +The reference `MemoryContentSink`, `MemoryLicenseStore`, reference server maps, +fixture wallets, and in-memory ledgers are deliberately inspectable. Replace +them with transactional durable stores before serving real purchases. Across +replicas, a repeated identical request must return the same result; a conflicting +transaction for an already claimed Demand or Authorization must fail atomically. + +## Failure And Recovery Matrix + +| Failure | Required behavior | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| One CHIRP host fails during upload | Continue only if the requested resilience level can still be committed; retain the checkpoint for resumable hosts | +| A host omits `Content-Length` | Enforce the expected referenced length while streaming; the header is advisory, not an integrity dependency | +| A host returns bad object bytes | Reject before release and retry another resolved host within configured limits | +| A full CHIRP stream ends early or has the wrong hash | Reject terminal validation; do not treat previously consumed bytes as an atomic verified file | +| Quote or readiness expires before wallet confirmation | Refresh readiness or obtain a new Quote before creating a transaction | +| A Payee is offline before payment | Preflight fails; no transaction should be created | +| A receipt-complete Payee is offline after payment | Persist pending state and retry the same signed Delivery through `recoveryUntil` | +| An authorized-output Payee is offline after payment | Obtain only the exact signed provider evidence the Authorization named; otherwise remain pending | +| Evidence or Delivery provider is unavailable | Remain pending and retry; do not weaken the profile or create another transaction | +| Completion response is lost | Call `recover(endpoint, requestId)` and reconcile the returned License before retrying completion | +| Reorganization or mined-state policy matters | Use a separately defined proof/finality profile; signed processor acceptance does not claim mining | + +## Security And Privacy Checklist + +- Bound header size, CBOR depth and entries, logical bytes, object size and + count, redirects, retries, concurrency, cache use, composition depth, and + authority depth before processing untrusted input. +- Verify every hash, identifier, signature, time window, network binding, + selection, settlement profile, proof, and key grant. Unknown critical + extensions and unsupported profiles fail closed. +- Authenticate every LCH encryption segment before exposing plaintext. Keep + plaintext, CEKs, wallet credentials, upload capabilities, and recovery state + out of logs and public object stores. +- Treat `mediaType`, names, human terms, ODRL mappings, C2PA assertions, and + application metadata as untrusted until the application applies its own + rendering and policy rules. +- On servers, reject private, loopback, link-local, multicast, and otherwise + disallowed endpoint resolution; revalidate redirects; pin the connected + address; and never forward credentials across origins. CORS is not + authorization. +- Scope wallet and service credentials to one role. Keep wallet modules and + secret material outside public images. Encrypt CEKs and sensitive durable + state at rest, back them up, rotate access, and test restoration. +- Keep `LICENSE.txt`, `THIRD_PARTY_NOTICES.md`, and the applicable `LICENSES/` + archive with redistributed package and reference-app artifacts. + +## Operations And Observability + +Use stable identifiers rather than content or keys in telemetry. Useful fields +include `assetId`, `offerId`, `requestId`, `demandId`, CHIRP root identifier, +role, operation, settlement profile, transaction state, attempt, host, bounded +byte count, latency, and stable error code. Redact Atomic BEEF, derivation +material, signed Deliveries, licenses, keys, wallet responses, authorization +headers, and upload-session capabilities. + +Alert on: + +- advertised CHIRP roots with missing closure objects or retention near expiry; +- repeated object hash failures, host exhaustion, or terminal content-hash + failures; +- Quotes funded but not completed, especially near `recoveryUntil`; +- conflicting Demand or Authorization transaction claims; +- Delivery retention shorter than its signed promise; +- key unwrap, signature, authority, revocation, or profile-validation failures; +- wallet internalization failures and recovery backlog by Payee. + +Backups are only useful after a restore test. Validate a restored deployment by +publishing and resolving a complete CHIRP closure, acquiring a small LCH through +each enabled settlement profile, recovering after a simulated lost completion +response, and proving a Payee can retrieve and internalize an authorized +Delivery after restart. + +## Composition And Application Profiles + +LCH v1 `whole-placement-v1` is intentionally conservative. Repetition, +trimming, reversal, time-warping, distortion, mixing, and spatial placement may +be described as application or C2PA metadata. Any nonempty derivative selection +activates the placement's complete declared source selection; edit metadata +does not silently reduce payment or permission requirements. + +Use a separately registered critical mapping profile only when independent +implementations need deterministic partial mapping or proportional allocation. +Catalogue schemas, playlists, timelines, waveform indexes, social metadata, +recommendations, royalty formulas, streaming manifests, and media-aware CHIRP +chunking can evolve outside the core. Consumers that do not implement an +unknown critical profile must reject it rather than guess. + +## Production Readiness Gate + +Before enabling real purchases, verify all of the following: + +- the app shows exact signed terms, selections, Payees, amounts, splits, and + settlement profiles before its only wallet-creation boundary; +- funded state is durably committed before any Delivery is sent; +- every role uses a production BRC-100 wallet and independent least-privilege + credentials; +- every in-memory map, ledger, content store, and fixture is replaced or the + route is disabled; +- every asset fits the configured bounded LCH `Uint8Array` path, or a separately + reviewed segment-authenticating streaming adapter is deployed; +- retry, idempotency, conflict, timeout, expiry, offline Payee, provider outage, + lost response, late retrieval, and restore tests pass; +- CHIRP host count and retention satisfy the application's availability goal, + and renewals are monitored; +- server endpoint policy prevents SSRF and DNS rebinding at connection time; +- package, browser, Node, exact-tarball, license, conformance, and integration + suites pass against the versions being deployed; +- operators can recover by `requestId` without creating a replacement payment; + and +- unknown future chunking, settlement, evidence, key-delivery, enforcement, + composition-mapping, and critical extension identifiers fail closed. + +## Agent Implementation Contract + +An agent integrating these packages should keep these invariants in its task +plan and verification notes: + +1. Name which layer owns each requirement; do not add licensing semantics to + CHIRP or storage semantics to LCH objects. +2. Link the published BRC section and the exact package API used for every + protocol decision. +3. Preserve old UHRP paths and identifiers when adding CHIRP. +4. Keep wallet transaction creation explicit and user-authorized. +5. Persist before fan-out, retry the same transaction, and implement recovery + before presenting another purchase action. +6. Model every Payee endpoint, wallet, and ledger as independently operated, + even when a test topology collapses them. +7. Validate unknown-profile, resource-limit, SSRF, bad-hash, offline, timeout, + duplicate, conflicting-transaction, and restart cases. +8. State which reference stores and fixtures were replaced for production. +9. Run package and exact-packed-artifact tests and retain third-party notices. +10. Record deployment ownership, retention, renewal, rollback, and restore + evidence without exposing secrets or payment material. + +## Reference Map + +- [BRC-167 CHIRP](https://bsv.brc.dev/overlays/0167) — normative CHIRP format, + host behavior, upload sessions, resolution, and forward compatibility. +- [BRC-170 LCH](https://bsv.brc.dev/apps/0170) — normative LCH framing, + profiles, acquisition, settlement, key delivery, and composition. +- [`@bsv/chirp` package guide](../packages/network/chirp.md) — public API, + compatibility, CLI, and limits. +- [`@bsv/lch` package guide](../packages/content/lch.md) — public API, + settlement profiles, storage adapters, and security notes. +- [LCH reference workbench](https://github.com/bsv-blockchain/ts-stack/tree/main/apps/lch-reference) + — executable creator, server, player, profile runner, edge cases, and notices. +- [UHRP specification and TS APIs](../specs/uhrp.md) — unchanged base storage + addressing and discovery. +- [BRC-100 wallet guide](./wallet-aware-app.md) — wallet connection and explicit + transaction boundaries. diff --git a/docs/guides/index.md b/docs/guides/index.md index f98e81ff6..acaf6e6ad 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -3,8 +3,8 @@ id: guides-overview title: 'Guides' kind: meta version: '1.0.0' -last_updated: '2026-07-27' -last_verified: '2026-08-26' +last_updated: '2026-08-28' +last_verified: '2026-08-28' review_cadence_days: 30 status: stable tags: [guides, tutorials, how-to] @@ -50,12 +50,22 @@ the exact packed workspace artifacts. **Time:** ~10 minutes | **Level:** Intermediate +### 6. [Build Production CHIRP and LCH Applications](./chirp-lch-production.md) + +Publish large verified ciphertext, license it, pay independently operated +rights controllers, recover safely after transaction creation, and deploy the +creator, issuer, Payee, wallet, evidence, Delivery, and storage roles. + +**Time:** ~45 minutes | **Level:** Advanced + ## Recommended Learning Path 1. Start with **Wallet-Aware App** if you're new to wallets and transactions 2. Learn **P2P Messaging** to understand identity and authentication 3. Explore **Overlay Node** for understanding data indexing and discovery 4. Master **HTTP 402 Payments** to monetize your services +5. Use **Production CHIRP and LCH Applications** for large licensed media, + multilateral settlement, and resilient content delivery ## Quick Links diff --git a/docs/packages/content/index.md b/docs/packages/content/index.md new file mode 100644 index 000000000..1b43c5b98 --- /dev/null +++ b/docs/packages/content/index.md @@ -0,0 +1,28 @@ +--- +id: content-packages +title: Content +kind: meta +domain: content +version: 'n/a' +last_updated: '2026-08-28' +last_verified: '2026-08-28' +review_cadence_days: 30 +status: experimental +tags: ['content', 'licensing', 'media'] +--- + +# Content + +Content packages bind encrypted media and other creative works to portable +rights, acquisition, key-delivery, provenance, and composition records. + +| Package | Purpose | +| -------------------- | -------------------------------------------------------------------------- | +| [@bsv/lch](./lch.md) | Build and consume BRC-170 licensed-content objects and composition records | + +Storage remains a separate concern. LCH ciphertext can use ordinary HTTPS, +UHRP, or CHIRP without changing those protocols. + +Start with the [production CHIRP and LCH guide](../../guides/chirp-lch-production.md) +for end-to-end creator, buyer, Payee, wallet, storage, recovery, and deployment +architecture. diff --git a/docs/packages/content/lch.md b/docs/packages/content/lch.md new file mode 100644 index 000000000..cc4a482db --- /dev/null +++ b/docs/packages/content/lch.md @@ -0,0 +1,204 @@ +--- +id: lch +title: '@bsv/lch' +kind: package +domain: content +npm: '@bsv/lch' +version: '0.1.0' +last_updated: '2026-08-28' +last_verified: '2026-08-28' +review_cadence_days: 30 +repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch' +status: experimental +tags: ['content', 'licensing', 'brc-170', 'odrl', 'c2pa', 'chirp', 'uhrp'] +--- + +# @bsv/lch + +> Browser- and Node-compatible reference implementation of published BRC-170 +> Licensed Content Header protocol. + +## Install + +```bash +npm install @bsv/lch @bsv/sdk +# Add @bsv/chirp when using chirp: ciphertext locators. +``` + +## What it provides + +- strict deterministic CBOR, typed object identifiers, and `.lch` framing; +- segmented AES-256-GCM with authenticated range decryption and key periods; +- BRC-77 public signatures and BRC-78 peer-specific key envelopes; +- signed Assets, Offers, Demands, readiness leases, destination Authorizations, + transaction evidence, Delivery acknowledgements, Licenses, Authorities, and + receipts; +- explicit BRC-29/BRC-100 multilateral payment construction that matches + finalized wallet outputs without assuming output order; +- deterministic-CBOR Fetch client/server bindings, typed acquisition builders, + replay-safe Payee receipt handling, authorized-output fallback and late + retrieval, an injectable acquisition transport, and a complete multipay + buyer workflow across independently routed Payees and providers; +- bounded authority chains with fresh, network-scoped revocation observations; +- HTTPS endpoint policy plus UHRP and CHIRP content source and sink adapters; +- browser IndexedDB and in-memory license stores; and +- whole-placement composition records with cycle and depth checks. + +## Acquisition is explicit + +Reading a header never spends money. Applications inspect an Offer, preflight +the exact selection and price, show a user confirmation, ask the wallet to +construct payment, deliver the Demand and remittance to the Payee, then store +the returned signed License and key grants. Recovery is available for the +Offer's exact declared recovery period. + +```typescript +const buyer = await LCHMultipayBuyer.create(wallet, { endpointPolicy }) +const request = await buyer.createRequest({ + offerId, + assetId, + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest, + createdAt: BigInt(Math.floor(Date.now() / 1000)) +}) +const plan = await buyer.quote(acquisitionEndpoint, request, issuerIdentity) + +// Display plan.totalSatoshis, plan.demands, action, Selection, and terms first. + +// Application UI must obtain consent before this boundary. +const funded = await buyer.createPayment(plan) +await durableRecoveryStore.put(funded) + +const receipts = [] +const authorizedOutputs = [] +for (const delivery of funded.deliveries) { + const proof = await buyer.settleDelivery(funded, delivery) + if (proof.type === 'receipt') receipts.push(proof.receipt) + else authorizedOutputs.push(proof.evidence) +} +const license = await buyer.complete(funded, receipts, authorizedOutputs) +``` + +Wallets may add or reorder outputs. The implementation locates every Demand +output after finalization by its exact locking script and satoshi amount, and +fails if a match is missing or ambiguous. Retain `funded`, Receipts, +Authorizations, and provider evidence until completion or `recoveryUntil`; +retry with the same transaction after ambiguity and never create a replacement +automatically. + +The issuer endpoint coordinates Quote, completion, and recovery. Every signed +Demand selects its own Payee endpoint and settlement profile. +`receipt-complete-v1` requires Payee wallet internalization and a Receipt. +`authorized-output-v1` permits a Payee to sign its exact BRC-29 destination, +accepted-transaction provider, and durable Delivery provider before payment. +The fallback releases a License only after all signed evidence verifies; the +Payee later retrieves and internalizes the same Delivery. Those endpoints can +be different origins and operators. `LCHAcquisitionTransport` defaults to the HTTP binding and +also gives applications a stable seam for an asynchronous inbox or message-box +adapter without changing signed objects or recovery behavior. + +On the receiving side, `WalletPaymentReceiver` independently derives and +validates the Payee's BRC-29 output, atomically claims the Demand through a +`PaymentLedger`, calls that Payee wallet's BRC-100 `internalizeAction`, and +returns a signed Receipt. `LCHHttpServer` mounts that and the issuer handlers on +independent Fetch-compatible server surfaces. `WalletAuthorizedOutputPayee` +and `LCHSettlementService` expose the opt-in provider roles. Silence, a +finalized transaction, insufficient retention, or an unknown evidence policy +never satisfies a Demand. The executable reference +application also ships a Node server, creator wizard, player, wallet-module +contract, and collapsed, federated, container, and durable deployment examples. + +The issuer is a coordinator, not an implicit payment custodian. Every Demand +names the Payee identity, amount, BRC-29 derivation, settlement profile, and +endpoint. A drummer, composer, label, or publisher can each run an independent +wallet, endpoint, Payment Ledger, and availability provider. The Quote binds +their signed Demands into one buyer-authorized transaction without moving +those wallets into the issuer service. + +## Storage and media + +`UniversalContentSource` resolves `chirp:`, `uhrp:`, and bounded HTTPS +locators. `CHIRPContentSink` and `UHRPContentSink` publish encrypted bytes +through the existing storage libraries. No LCH API changes the existing UHRP +uploader, downloader, routes, or identifiers. + +Media players and DAWs remain application code. The package supplies verified +bytes and selection/composition semantics; it does not choose codecs, decode +media, draw waveforms, build catalogues, or define recommendation behavior. + +For large licensed media, use `CHIRPContentSink` with a `CHIRPUploader` and +configure `UniversalContentSource` with a `CHIRPDownloader`. CHIRP validates +Merkle objects, logical length, and the complete-stream content hash; LCH then +independently validates the exact ciphertext length and digest before segment +authentication and decryption. Storage hosts see ciphertext and do not become +LCH issuers or Payees. + +The 0.1 LCH publisher and reader use bounded `Uint8Array` representations and +assemble the complete resolved ciphertext in memory. Configure +`maximumBytes` for assets using this path. CHIRP supports verified streaming, +but a progressive LCH player additionally needs a segment-aware adapter that +authenticates complete encryption records and enforces the licensed Selection; +raw CHIRP chunks are ciphertext, not authenticated playable plaintext. + +## Composition boundary + +The core profile supports repeated whole-placement ingredients. Each placement +is independently attributable even when the same source asset appears more +than once. Trimming, time-warping, reversal, distortion, mixing, spatial +placement, and other editorial operations can be described in non-critical +application or C2PA metadata without changing permission or settlement +semantics. Whole placement conservatively activates the ingredient's complete +declared source selection. A separately registered critical mapping profile is +needed only for deterministic selective mapping or proportional allocation; a +consumer that does not implement one must fail closed. + +Integer time windows are half-open: `notBefore` is inclusive and `notAfter` is +exclusive. Fractional edit values such as playback rates use exact integer +ratios because deterministic LCH CBOR prohibits floats. + +Training alone does not create a composition claim. A creator may identify +specific source works individually; dataset roots and batch attestations remain +future profiles. + +## Security notes + +- Verify every signed object and recompute every identifier before use. +- Reject stale, unknown, wrong-network, spent, or reorg-affected revocation + observations. +- Permit local HTTP endpoints only through an explicit development override. +- Resolve DNS again for redirects and connections, and use the endpoint + policy's address-pinning connector to prevent rebinding; do not forward + credentials across origins. +- Authenticate every segment before exposing plaintext. Whole-asset grants + must include every key period intersecting the licensed selection. +- Keep the scoped `THIRD_PARTY_NOTICES.md` in the npm artifact. The package + incorporates no third-party source and the authorized-output profile adds no + dependency; peer packages retain their own notices. +- Prefer receipt-complete when a Payee does not accept disclosure of its exact + destination, dependence on named providers, accepted-before-mined evidence, + or License release before wallet internalization. +- Treat ODRL/C2PA mappings as evidence and policy description, not as a + substitute for payment settlement or authority validation. + +## Production readiness + +Replace every fixture wallet and in-memory content, issuer, license, Payment +Ledger, Authorization, evidence, and Delivery store before real purchases. +Persist finalized Atomic BEEF and signed Deliveries before fan-out; retry and +recover the same transaction through `recoveryUntil`; keep every endpoint +independently routable; and validate restart, duplicate, conflict, offline, +provider-outage, and lost-response cases. The +[production CHIRP and LCH guide](../../guides/chirp-lch-production.md) includes +copyable combined-layer flows, role ownership, a failure matrix, server and +wallet topology, rollout/rollback guidance, observability, and an agent +implementation contract. + +## Reference + +- [Package README](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch#readme) +- [Published BRC-170](https://bsv.brc.dev/apps/0170) +- [BRC-170 source](https://github.com/bsv-blockchain/BRCs/blob/master/apps/0170.md) +- [Production CHIRP and LCH guide](../../guides/chirp-lch-production.md) +- [Reference application](https://github.com/bsv-blockchain/ts-stack/tree/main/apps/lch-reference) +- [Source on GitHub](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch) diff --git a/docs/packages/index.md b/docs/packages/index.md index a126237e9..bb4862917 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -3,8 +3,8 @@ id: packages-index title: Packages kind: meta version: 'n/a' -last_updated: '2026-07-27' -last_verified: '2026-08-26' +last_updated: '2026-08-28' +last_verified: '2026-08-28' review_cadence_days: 30 status: stable tags: ['packages'] @@ -12,9 +12,9 @@ tags: ['packages'] # Packages -ts-stack contains packages organized into 7 domains. Each domain serves a specific part of the stack — from core crypto to business logic. See the top-level README.md for the current package map. +ts-stack contains packages organized into 8 domains. Each domain serves a specific part of the stack — from core crypto to licensed content and business logic. See the top-level README.md for the current package map. -## Seven Domains +## Eight Domains ### SDK @@ -38,8 +38,15 @@ ts-stack contains packages organized into 7 domains. Each domain serves a specif **P2P Real-time Event Listener for Teranode** +- [@bsv/chirp](./network/chirp.md) — Chunked Merkle publication and resilient retrieval over UHRP - [@bsv/teranode-listener](./network/teranode-listener.md) — Subscribe to Teranode P2P topics (blocks, subtrees, mining) with callbacks (see specs/sync for related) +### Content + +**Licensed, encrypted, attributable creative content and composition.** + +- [@bsv/lch](./content/lch.md) — BRC-170 framing, rights, acquisition, key delivery, provenance, and whole-placement composition + ### Overlays **Run and consume overlay services that index on-chain data.** diff --git a/docs/packages/network/chirp.md b/docs/packages/network/chirp.md index db8b8a1c2..5f34b63a7 100644 --- a/docs/packages/network/chirp.md +++ b/docs/packages/network/chirp.md @@ -5,8 +5,8 @@ kind: package domain: network npm: '@bsv/chirp' version: '0.1.1' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-08-28' +last_verified: '2026-08-28' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/chirp' status: experimental @@ -83,6 +83,22 @@ Unknown critical extensions and unsupported node or child kinds fail closed. media-aware profiles, proofs, collections, and erasure coding remain reserved for later compatible specifications. +## Production readiness + +Set `resilienceLevel` to the number of complete hosts required before +publication succeeds, protect and retain resumable upload checkpoints, and +monitor root retention and renewal. Bound readers by logical and object bytes, +object count, depth, redirects, retries, concurrency, and cache use. A +server-side `urlPolicy` must constrain DNS and pin the connected address; a +preflight lookup alone does not prevent rebinding. + +For licensed media, CHIRP should store LCH ciphertext, not plaintext or keys. +`CHIRPContentSink` bridges the uploader and LCH representation while +`UniversalContentSource` bridges the downloader and LCH reader. See the +[production CHIRP and LCH guide](../../guides/chirp-lch-production.md) for the +combined code path, ownership model, failure matrix, rollout gate, and agent +checklist. + ## CLI ```bash @@ -95,6 +111,8 @@ chirp verify chirp://... ## Reference - [Package README](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/chirp#readme) -- [BRC-167 proposal](https://github.com/bsv-blockchain/BRCs/pull/235) +- [Published BRC-167](https://bsv.brc.dev/overlays/0167) +- [BRC-167 source](https://github.com/bsv-blockchain/BRCs/blob/master/overlays/0167.md) +- [Production CHIRP and LCH guide](../../guides/chirp-lch-production.md) - [Source on GitHub](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/chirp) - [npm](https://www.npmjs.com/package/@bsv/chirp) diff --git a/docs/reference/npm-package-supply-chain.md b/docs/reference/npm-package-supply-chain.md index 64b067b98..2ebc2d04e 100644 --- a/docs/reference/npm-package-supply-chain.md +++ b/docs/reference/npm-package-supply-chain.md @@ -12,7 +12,7 @@ tags: [reference, packages, npm, security, releases] # npm Package Supply Chain -All 32 public packages are released from `.github/workflows/release.yaml`. The +All 33 public packages are released from `.github/workflows/release.yaml`. The workflow is the only supported publication path. It uses the protected `npm-production` environment and npm trusted publishing (OIDC); release automation must not use a long-lived npm write token. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index cebeddc95..dc282d37d 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -12,7 +12,7 @@ tags: [reference, packages, api, declarations, migrations, release-notes] # Package API, Declarations, and Migration Ledger -This page is generated from all 32 public manifests, package documentation, and +This page is generated from all 33 public manifests, package documentation, and `governance/package-release-notes.json`. It records source candidates without publishing them. CI rejects a version change unless its release classification, summary, and migration guidance are updated at the same time. @@ -23,40 +23,41 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/air-gap` | `0.0.0` | `0.1.2` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | -| `@bsv/amountinator` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | -| `@bsv/auth` | `0.1.1` | `0.1.4` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | -| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.3` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket` | `2.1.1` | `2.1.7` | patch | [API and usage](../packages/messaging/authsocket.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/btms` | `1.1.1` | `1.2.2` | minor | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | -| `@bsv/btms-permission-module` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | -| `@bsv/chirp` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/network/chirp.md) | No consumer migration is required; this is the first release of a new additive package. Existing @bsv/sdk StorageUploader, StorageDownloader, StorageUtils, UHRP identifiers, overlays, and server routes remain unchanged. BRC-167 remains authoritative if the implementation and standard differ. | -| `@bsv/did` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | -| `@bsv/did-client` | `1.2.1` | `1.3.1` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/fund-wallet` | `1.4.1` | `1.4.4` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/gasp` | `1.3.1` | `1.3.6` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/message-box-client` | `2.4.0` | `2.4.2` | patch | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/overlay` | `2.2.1` | `2.3.1` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.1` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-express` | `2.5.0` | `2.6.1` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-topics` | `1.6.10` | `1.7.1` | minor | [API and usage](../packages/overlays/overlay-topics.md) | Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. | -| `@bsv/paymail` | `2.4.2` | `2.4.7` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. Consumers of the former bundled Money Button or Tokenized specification documents must follow the authoritative links in docs/specs/README.md. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/sdk` | `2.4.0` | `2.4.2` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. | -| `@bsv/simple` | `0.4.1` | `0.5.2` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/templates` | `1.9.1` | `1.10.1` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/teranode-listener` | `1.1.1` | `1.1.5` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.0` | `0.3.5` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.1` | `0.1.7` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/wallet-relay` | `0.2.2` | `0.3.6` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No runtime consumer migration is required. Canonical AtomicBEEF, number-array behavior, and pagination contracts are unchanged; upgrade to @bsv/sdk 2.4.2 or later. Documentation users should use docs/storage.md instead of the removed JSight export. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/air-gap` | `0.0.0` | `0.1.2` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | +| `@bsv/amountinator` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | +| `@bsv/auth` | `0.1.1` | `0.1.4` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | +| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.3` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket` | `2.1.1` | `2.1.7` | patch | [API and usage](../packages/messaging/authsocket.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/btms` | `1.1.1` | `1.2.2` | minor | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | +| `@bsv/btms-permission-module` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | +| `@bsv/chirp` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/network/chirp.md) | No consumer migration is required; this is the first release of a new additive package. Existing @bsv/sdk StorageUploader, StorageDownloader, StorageUtils, UHRP identifiers, overlays, and server routes remain unchanged. BRC-167 remains authoritative if the implementation and standard differ. | +| `@bsv/did` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | +| `@bsv/did-client` | `1.2.1` | `1.3.1` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/fund-wallet` | `1.4.1` | `1.4.4` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/gasp` | `1.3.1` | `1.3.6` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/lch` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/content/lch.md) | No consumer migration is required; this is the first release of a new additive package. Applications must keep createPayment behind explicit wallet authorization, persist the funded transaction and every partial settlement proof through recovery, retry with that same transaction, distinguish finalized from accepted evidence, and fail closed on unknown settlement or evidence profiles. Payees should select receipt-complete unless they explicitly accept authorized-output provider, privacy, and pre-internalization key-release tradeoffs. Distributors must retain THIRD_PARTY_NOTICES.md with the package; the new profile adds no dependency. Published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.4.0` | `2.4.2` | patch | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/overlay` | `2.2.1` | `2.3.1` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.1` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-express` | `2.5.0` | `2.6.1` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-topics` | `1.6.10` | `1.7.1` | minor | [API and usage](../packages/overlays/overlay-topics.md) | Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. | +| `@bsv/paymail` | `2.4.2` | `2.4.7` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. Consumers of the former bundled Money Button or Tokenized specification documents must follow the authoritative links in docs/specs/README.md. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/sdk` | `2.4.0` | `2.4.2` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. | +| `@bsv/simple` | `0.4.1` | `0.5.2` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/templates` | `1.9.1` | `1.10.1` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/teranode-listener` | `1.1.1` | `1.1.5` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.0` | `0.3.5` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.1` | `0.1.7` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/wallet-relay` | `0.2.2` | `0.3.6` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | +| `@bsv/wallet-toolbox` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No runtime consumer migration is required. Canonical AtomicBEEF, number-array behavior, and pagination contracts are unchanged; upgrade to @bsv/sdk 2.4.2 or later. Documentation users should use docs/storage.md instead of the removed JSight export. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -234,6 +235,20 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. | `.` | `./dist/esm/mod.js`
    `./dist/cjs/mod.js` | `./dist/types/mod.d.ts`
    `./dist/cjs/mod.d.ts` | | `./*.ts` | `./dist/esm/src/*.js`
    `./dist/cjs/src/*.js` | `./dist/types/src/*.d.ts`
    `./dist/cjs/src/*.d.ts` | +## @bsv/lch + +- Package documentation: [docs/packages/content/lch.md](../packages/content/lch.md) +- Source: [packages/content/lch](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch) +- Release note: Introduces the published BRC-170 Licensed Content Header reference implementation: deterministic CBOR and framing, segmented authenticated encryption, signed acquisition objects, deterministic-CBOR HTTP client/server bindings, independently routed and replay-safe Payee receipts, signed readiness and pending settlement, receipt-complete and offline-capable authorized-output settlement profiles, authenticated late Delivery retrieval, an explicit recovery-safe multipay buyer workflow, UHRP and CHIRP content adapters, authority revocation, and whole-placement composition. +- Migration: No consumer migration is required; this is the first release of a new additive package. Applications must keep createPayment behind explicit wallet authorization, persist the funded transaction and every partial settlement proof through recovery, retry with that same transaction, distinguish finalized from accepted evidence, and fail closed on unknown settlement or evidence profiles. Payees should select receipt-complete unless they explicitly accept authorized-output provider, privacy, and pre-internalization key-release tradeoffs. Distributors must retain THIRD_PARTY_NOTICES.md with the package; the new profile adds no dependency. Published BRC-170 remains authoritative if the implementation and standard differ. + +CLI entry points: `{"lch":"./dist/cli.js"}`. + +| Public subpath | Runtime target(s) | Declaration target(s) | +| ---------------- | -------------------------------------- | --------------------- | +| `.` | `./dist/index.js`
    `./dist/index.js` | `./dist/index.d.ts` | +| `./package.json` | `./package.json` | — | + ## @bsv/message-box-client - Package documentation: [docs/packages/messaging/message-box-client.md](../packages/messaging/message-box-client.md) diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 9d917980f..37a1065f9 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -31,12 +31,13 @@ Node consumers; they do not require a browser or mobile device to provide Node A ## Public package manifest -The release graph currently contains **32 public packages**. Versions +The release graph currently contains **33 public packages**. Versions below are source-manifest versions; registry publication is a separate, explicitly authorized release action. | Area | Package | Source version | Project profile | Consumer profiles | Runtime targets | Node engine | Source | | --- | --- | --- | --- | --- | --- | --- | --- | +| content | `@bsv/lch` | `0.1.0` | browser-library | browser-bundler, browser-esm, cli, node-esm | browser, node | `>=22` | [packages/content/lch](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch) | | helpers | `@bsv/air-gap` | `0.1.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/air-gap](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) | | helpers | `@bsv/amountinator` | `2.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | | helpers | `@bsv/did` | `0.2.5` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | @@ -89,9 +90,9 @@ the separately released and verified image digest. | Metric | Count | | --- | --- | -| Governed projects | 39 | -| Package-area projects | 35 | -| Public npm packages | 32 | +| Governed projects | 41 | +| Package-area projects | 36 | +| Public npm packages | 33 | | Private package-area projects | 3 | | Standalone infrastructure projects | 7 | @@ -126,7 +127,7 @@ targets have been completed. | Metric | Current value | Authority | | --- | --- | --- | -| Projects with a test:coverage script | 34 | current package manifests | +| Projects with a test:coverage script | 35 | current package manifests | | Aggregate line coverage | 66.97% | https://app.codecov.io/gh/BSV-blockchain/ts-stack | | Reported source files | 543 | https://app.codecov.io/gh/BSV-blockchain/ts-stack | | Reported lines (hit / missed / partial) | 30981 / 11619 / 3659 | https://app.codecov.io/gh/BSV-blockchain/ts-stack | diff --git a/docs/specs/index.md b/docs/specs/index.md index 6ebcec806..0fa854a10 100644 --- a/docs/specs/index.md +++ b/docs/specs/index.md @@ -3,8 +3,8 @@ id: specs-index title: Specifications kind: meta version: 'n/a' -last_updated: '2026-04-28' -last_verified: '2026-08-26' +last_updated: '2026-08-28' +last_verified: '2026-08-28' review_cadence_days: 30 status: stable tags: ['specs'] @@ -18,27 +18,29 @@ This section documents the protocols and standards that the ts-stack implements. ## Quick Reference -| Spec | Format | Version | Implementations | Purpose | -| ------------------------------------------------- | ------------------ | ------- | --------------------------------------------- | -------------------------------------------------------- | -| [BRC-100 Wallet](./brc-100-wallet.md) | JSON Schema | 1.0.0 | @bsv/wallet-toolbox, @bsv/sdk | Standard wallet interface for signing and key management | -| [BRC-31 Auth](./brc-31-auth.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/auth-express-middleware, @bsv/authsocket | Mutual authentication handshake (BRC-103 + BRC-104) | -| [BRC-29 Peer Payment](./brc-29-peer-payment.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/paymail, @bsv/message-box-client | P2P payment derivation and transmission | -| [BRC-121 / 402](./brc-121-402.md) | OpenAPI 3.1 | 1.0.0 | @bsv/402-pay | HTTP micropayment protocol | -| [Overlay HTTP](./overlay-http.md) | OpenAPI 3.1 | 1.0.0 | @bsv/overlay, @bsv/overlay-express | Transaction routing and topic management | -| [Message Box HTTP](./message-box-http.md) | OpenAPI 3.1 | 1.0.0 | @bsv/message-box-client | Store-and-forward messaging API | -| [AuthSocket](./authsocket.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/authsocket | Authenticated WebSocket for live messaging | -| [ARC Broadcast](./arc-broadcast.md) | OpenAPI 3.1 | 1.0.0 | @bsv/sdk | Miner-facing transaction broadcast | -| [Merkle Service](./merkle-service.md) | OpenAPI 3.1 | 1.0.0 | @bsv/sdk | SPV proof delivery service | -| [Storage Adapter](./storage-adapter.md) | OpenAPI 3.1 | 1.0.0 | @bsv/wallet-toolbox | Remote wallet storage interface | -| [GASP Sync](./gasp-sync.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/gasp | Transaction graph synchronization | -| [UHRP](./uhrp.md) | OpenAPI 3.1 | 1.0.0 | @bsv/overlay-topics | Content-addressed file storage | -| [Air-Gap Optical (BRC-141)](./air-gap-optical.md) | Markdown wire spec | 1.0.0 | @bsv/air-gap | One-directional optical air-gap transport (experimental) | +| Spec | Format | Version | Implementations | Purpose | +| --------------------------------------------------------------- | ------------------ | ------- | --------------------------------------------- | -------------------------------------------------------- | +| [BRC-100 Wallet](./brc-100-wallet.md) | JSON Schema | 1.0.0 | @bsv/wallet-toolbox, @bsv/sdk | Standard wallet interface for signing and key management | +| [BRC-31 Auth](./brc-31-auth.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/auth-express-middleware, @bsv/authsocket | Mutual authentication handshake (BRC-103 + BRC-104) | +| [BRC-29 Peer Payment](./brc-29-peer-payment.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/paymail, @bsv/message-box-client | P2P payment derivation and transmission | +| [BRC-121 / 402](./brc-121-402.md) | OpenAPI 3.1 | 1.0.0 | @bsv/402-pay | HTTP micropayment protocol | +| [Overlay HTTP](./overlay-http.md) | OpenAPI 3.1 | 1.0.0 | @bsv/overlay, @bsv/overlay-express | Transaction routing and topic management | +| [Message Box HTTP](./message-box-http.md) | OpenAPI 3.1 | 1.0.0 | @bsv/message-box-client | Store-and-forward messaging API | +| [AuthSocket](./authsocket.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/authsocket | Authenticated WebSocket for live messaging | +| [ARC Broadcast](./arc-broadcast.md) | OpenAPI 3.1 | 1.0.0 | @bsv/sdk | Miner-facing transaction broadcast | +| [Merkle Service](./merkle-service.md) | OpenAPI 3.1 | 1.0.0 | @bsv/sdk | SPV proof delivery service | +| [Storage Adapter](./storage-adapter.md) | OpenAPI 3.1 | 1.0.0 | @bsv/wallet-toolbox | Remote wallet storage interface | +| [GASP Sync](./gasp-sync.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/gasp | Transaction graph synchronization | +| [UHRP](./uhrp.md) | OpenAPI 3.1 | 1.0.0 | @bsv/overlay-topics | Content-addressed file storage | +| [CHIRP (BRC-167)](../packages/network/chirp.md) | Published BRC | 1 | @bsv/chirp | Chunked, verified, resilient large-object storage | +| [Air-Gap Optical (BRC-141)](./air-gap-optical.md) | Markdown wire spec | 1.0.0 | @bsv/air-gap | One-directional optical air-gap transport (experimental) | +| [Licensed Content Header (BRC-170)](../packages/content/lch.md) | Published BRC | 1 | @bsv/lch | Licensed encrypted content, acquisition, and composition | ## About BRCs **BRC** = BSV Request for Comments. BRCs are numbered sequentially (there is no categorical grouping by number range). Each BRC solves a specific interoperability problem. Implementations reference the spec by number so different teams build compatible systems without central coordination. -The authoritative BRC repository is at [github.com/bitcoin-sv/BRCs](https://github.com/bitcoin-sv/BRCs). The machine-readable contracts for BRCs implemented in ts-stack live in the [`/specs`](https://github.com/bsv-blockchain/ts-stack/tree/main/specs) directory as OpenAPI 3.1, AsyncAPI 3.0, and JSON Schema files. +The authoritative BRC repository is at [github.com/bsv-blockchain/BRCs](https://github.com/bsv-blockchain/BRCs). Published rendered specifications include [BRC-167](https://bsv.brc.dev/overlays/0167) and [BRC-170](https://bsv.brc.dev/apps/0170). The machine-readable contracts for BRCs implemented in ts-stack live in the [`/specs`](https://github.com/bsv-blockchain/ts-stack/tree/main/specs) directory as OpenAPI 3.1, AsyncAPI 3.0, and JSON Schema files. ## By Use Case @@ -75,6 +77,15 @@ The authoritative BRC repository is at [github.com/bitcoin-sv/BRCs](https://gith - Use [Merkle Service](./merkle-service.md) for SPV proof delivery - Implement via `@bsv/sdk` or external Merkle Service +**I'm publishing or licensing large media** + +- Use [BRC-167](https://bsv.brc.dev/overlays/0167) and `@bsv/chirp` for + progressive, verified storage over UHRP +- Use [BRC-170](https://bsv.brc.dev/apps/0170) and `@bsv/lch` for encryption, + rights, acquisition, direct-to-Payee settlement, and composition +- Follow the [production CHIRP and LCH guide](../guides/chirp-lch-production.md) + for the combined architecture and recovery model + ## Learning Path 1. **Foundations** — Read [Key Concepts](../get-started/concepts.md) for UTXO model, scripts, and transactions diff --git a/governance/browser-artifact-policy.json b/governance/browser-artifact-policy.json index 4320fa7c3..d608a8cb9 100644 --- a/governance/browser-artifact-policy.json +++ b/governance/browser-artifact-policy.json @@ -82,6 +82,13 @@ "entry": ".", "splittingDisposition": "The browser entry contains only CHIRP codecs, builders, verifiers, upload/download clients, and the browser-safe SDK dependency; the Node CLI is a separate unexported bin entry." }, + { + "name": "@bsv/lch", + "path": "packages/content/lch", + "budget": "packages/content/lch/browser-budget.json", + "entry": ".", + "splittingDisposition": "The browser entry contains protocol codecs, crypto, acquisition, composition, and storage adapters; the Node CLI and optional C2PA implementation remain outside the browser graph." + }, { "name": "@bsv/sdk", "path": "packages/sdk", diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 7dbb9568c..62cb2044a 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -326,6 +326,16 @@ "minimumScore": 85, "maximumNoCoverage": 0, "maximumInvalid": 0 + }, + { + "id": "lch-cbor", + "manifest": "packages/content/lch/package.json", + "propertyTest": "packages/content/lch/test/cbor.property.test.ts", + "risk": "critical", + "boundary": "Untrusted BRC-170 deterministic CBOR objects crossing signature, identifier, payment, policy, and persistence boundaries", + "minimumScore": 80, + "maximumNoCoverage": 0, + "maximumInvalid": 0 } ] } diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index aa4510b65..f3d4db8b0 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -508,6 +508,53 @@ export function buildMutationTargets(repositoryRoot) { ['/test/codec.property.test.ts', '/test/primitives.test.ts'], { esm: true } ) + }, + 'lch-cbor': { + packageDirectory: 'packages/content/lch', + manifest: 'packages/content/lch/package.json', + propertyTest: 'packages/content/lch/test/cbor.property.test.ts', + mutate: [ + sourceLineRange( + repositoryRoot, + 'packages/content/lch', + 'src/cbor.ts', + 'function compareBytes(', + 'function encode(' + ), + sourceLineRange( + repositoryRoot, + 'packages/content/lch', + 'src/cbor.ts', + ' const entries = Object.entries(value)', + 'export function encodeDeterministicCbor(' + ), + sourceLineRange( + repositoryRoot, + 'packages/content/lch', + 'src/cbor.ts', + ' private decodeMap(', + ' done(): boolean' + ), + sourceLineRange( + repositoryRoot, + 'packages/content/lch', + 'src/cbor.ts', + ' private readLength(', + ' private read(' + ), + sourceLineRange( + repositoryRoot, + 'packages/content/lch', + 'src/cbor.ts', + 'export function decodeDeterministicCbor(', + '}' + ) + ], + ...jestTarget( + 'jest.config.js', + ['/test/cbor.property.test.ts', '/test/cbor.test.ts'], + { esm: true } + ) } } } diff --git a/governance/npm-package-supply-chain.json b/governance/npm-package-supply-chain.json index a4f0114e8..e37fd33ce 100644 --- a/governance/npm-package-supply-chain.json +++ b/governance/npm-package-supply-chain.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "artifactSchemaVersion": 1, - "publicPackageCount": 32, + "publicPackageCount": 33, "releaseWorkflow": ".github/workflows/release.yaml", "releaseEnvironment": "npm-production", "buildRuntime": { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 3aa969433..03a17bafc 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -73,6 +73,13 @@ "summary": "Introduces the BRC-167 CHIRP reference implementation: canonical Merkle codecs and vectors, progressive and resumable multi-host publication, bounded interleaved and range-aware resolution, a verified-object cache, browser and Node byte-source adapters, closure validation, and publication/retrieval/verification CLI commands. Standardizes first-party author metadata on the current BSV Association name.", "migration": "No consumer migration is required; this is the first release of a new additive package. Existing @bsv/sdk StorageUploader, StorageDownloader, StorageUtils, UHRP identifiers, overlays, and server routes remain unchanged. BRC-167 remains authoritative if the implementation and standard differ." }, + { + "name": "@bsv/lch", + "publishedVersion": "0.0.0", + "releaseType": "minor", + "summary": "Introduces the published BRC-170 Licensed Content Header reference implementation: deterministic CBOR and framing, segmented authenticated encryption, signed acquisition objects, deterministic-CBOR HTTP client/server bindings, independently routed and replay-safe Payee receipts, signed readiness and pending settlement, receipt-complete and offline-capable authorized-output settlement profiles, authenticated late Delivery retrieval, an explicit recovery-safe multipay buyer workflow, UHRP and CHIRP content adapters, authority revocation, and whole-placement composition.", + "migration": "No consumer migration is required; this is the first release of a new additive package. Applications must keep createPayment behind explicit wallet authorization, persist the funded transaction and every partial settlement proof through recovery, retry with that same transaction, distinguish finalized from accepted evidence, and fail closed on unknown settlement or evidence profiles. Payees should select receipt-complete unless they explicitly accept authorized-output provider, privacy, and pre-internalization key-release tradeoffs. Distributors must retain THIRD_PARTY_NOTICES.md with the package; the new profile adds no dependency. Published BRC-170 remains authoritative if the implementation and standard differ." + }, { "name": "@bsv/did", "publishedVersion": "0.2.1", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index f972de86c..ada507c53 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -4,9 +4,9 @@ "sourceRevision": "f9137ff037c6d608019d04b4e2f984812b0385b7", "tracker": "https://github.com/bsv-blockchain/ts-stack/issues/324", "workspace": { - "projects": 39, - "packageAreaProjects": 35, - "publicPackages": 32, + "projects": 41, + "packageAreaProjects": 36, + "publicPackages": 33, "privatePackageAreaProjects": 3 }, "ci": { @@ -298,6 +298,7 @@ }, "publicPackageVersions": { "@bsv/chirp": "0.1.1", + "@bsv/lch": "0.1.0", "@bsv/air-gap": "0.1.2", "@bsv/amountinator": "2.1.5", "@bsv/wallet-helper": "0.1.7", diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index a2b552e1e..64469e9f8 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -49,7 +49,7 @@ ".github/dependabot.yml" ], "created": "2026-07-27", - "reviewBy": "2026-08-27", + "reviewBy": "2026-09-27", "removeWhen": "Remove after TypeScript exposes a stable native API, every compiler-API consumer supports it without an override, and the full build, typecheck, declaration, packed-consumer, Jest, conformance, browser/mobile, and infrastructure matrix passes without @typescript/typescript6." }, { @@ -132,7 +132,7 @@ "category": "override", "target": "pnpm-workspace.yaml override typed-rest-client@2.3.1>qs", "owner": "ts-stack-maintainers", - "reason": "Stryker 9.6.1 is current but its current typed-rest-client 2.3.1 dependency pins vulnerable qs 6.15.1 exactly. GHSA-q8mj-m7cp-5q26 is fixed in qs 6.15.2 and later, so a parent-scoped substitution to 6.15.3 is the narrowest durable remediation. The previous lock-only selection was lost when an unrelated dependency change regenerated the graph.", + "reason": "The workspace uses Stryker 9.6.1, and the current Stryker 10.0.0 release still depends on typed-rest-client ~2.3.0. typed-rest-client 2.3.1 pins vulnerable qs 6.15.1 exactly. GHSA-q8mj-m7cp-5q26 is fixed in qs 6.15.2 and later, so a parent-scoped substitution to 6.15.3 remains the narrowest durable remediation. The previous lock-only selection was lost when an unrelated dependency change regenerated the graph.", "evidence": [ "pnpm-workspace.yaml#overrides", "https://github.com/advisories/GHSA-q8mj-m7cp-5q26", @@ -140,7 +140,7 @@ "https://github.com/bsv-blockchain/ts-stack/issues/324" ], "created": "2026-07-27", - "reviewBy": "2026-08-27", + "reviewBy": "2026-09-27", "removeWhen": "Remove when Stryker no longer depends on typed-rest-client 2.3.1 or a supported typed-rest-client release natively depends on qs 6.15.2 or newer, then regenerate the lock and rerun the complete mutation campaign." }, { diff --git a/governance/repository-health/projects.json b/governance/repository-health/projects.json index 667150c45..41ff1e92e 100644 --- a/governance/repository-health/projects.json +++ b/governance/repository-health/projects.json @@ -345,6 +345,16 @@ "runtimeTargets": ["node"], "release": "none" }, + { + "path": "apps/lch-reference", + "name": "lch-reference-app", + "owner": "ts-stack-maintainers", + "area": "examples", + "profile": "examples", + "criticality": "tier-2", + "runtimeTargets": ["browser", "node"], + "release": "none" + }, { "path": "conformance/runner", "name": "@bsv/conformance-runner", @@ -375,6 +385,17 @@ "runtimeTargets": ["browser", "node"], "release": "none" }, + { + "path": "packages/content/lch", + "name": "@bsv/lch", + "owner": "ts-stack-maintainers", + "area": "content", + "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "cli", "node-esm"], + "criticality": "tier-1", + "runtimeTargets": ["browser", "node"], + "release": "npm-oidc" + }, { "path": "packages/helpers/air-gap", "name": "@bsv/air-gap", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 7d1d37e8b..b7c8d447a 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -41,7 +41,8 @@ "packages/overlays/gasp-core/package.json", "packages/overlays/btms-backend/package.json", "packages/wallet/btms-permission-module/package.json", - "packages/network/chirp/package.json" + "packages/network/chirp/package.json", + "packages/content/lch/package.json" ], "suites": [ { @@ -435,6 +436,17 @@ "Every unsigned 64-bit value round-trips through the shortest canonical CompactSize representation.", "Every bounded byte source produces a deterministic root identifier and a hash-, length-, and content-verified closure." ] + }, + { + "path": "packages/content/lch/test/cbor.property.test.ts", + "manifest": "packages/content/lch/package.json", + "risk": "critical", + "boundary": "Untrusted BRC-170 deterministic CBOR objects crossing signature, identifier, payment, policy, and persistence boundaries", + "target": "Canonical deterministic CBOR round trips over arbitrary bounded supported values", + "invariants": [ + "Every supported value round-trips to the identical deterministic byte sequence.", + "Text and map keys remain normalized and canonical across independent encode and decode passes." + ] } ], "exclusions": [ diff --git a/governance/third-party-materials.json b/governance/third-party-materials.json index 3c6e700af..ef1a53de4 100644 --- a/governance/third-party-materials.json +++ b/governance/third-party-materials.json @@ -795,6 +795,17 @@ "preuniform-open-bsv4-standard" ] }, + { + "path": "packages/content/lch", + "packageName": "@bsv/lch", + "materials": [], + "reason": "The package has peer dependencies but incorporates no third-party source; retain an explicit scoped notice in the npm artifact." + }, + { + "path": "apps/lch-reference", + "inherits": "packages/sdk", + "reason": "The deployable browser bundle incorporates @bsv/sdk and must carry its scoped notice archive." + }, { "path": "packages/helpers/did-client", "packageName": "@bsv/did-client", diff --git a/packages/content/lch/AGENTS.md b/packages/content/lch/AGENTS.md new file mode 100644 index 000000000..dcea67c80 --- /dev/null +++ b/packages/content/lch/AGENTS.md @@ -0,0 +1,10 @@ +# ts-stack agent instructions + +This project follows the repository-wide [agent instructions](../../../AGENTS.md) +and [contribution policy](../../../CONTRIBUTING.md). Read and follow both files +before changing anything in this directory. + +Do not add package-local agent or contribution conventions. Put +package-specific technical information in the package README, `docs/`, +`specs/`, or the applicable operator guide, and propose shared policy at the +repository root. diff --git a/packages/content/lch/LICENSE.txt b/packages/content/lch/LICENSE.txt new file mode 100644 index 000000000..15e819500 --- /dev/null +++ b/packages/content/lch/LICENSE.txt @@ -0,0 +1,58 @@ +Open BSV License Version 6 – granted by BSV Association, Alpenstrasse 15, 6300 +Zug, Switzerland (CHE-427.008.338) ("Licensor"), to you as a user (henceforth +"You", "User" or "Licensee"). + +For the purposes of this license, the definitions below have the following +meanings: + +"Bitcoin Protocol" means the protocol implementation, cryptographic rules, +network protocols, and consensus mechanisms in the Bitcoin White Paper as +described here https://protocol.bsvblockchain.org. + +"Bitcoin White Paper" means the paper entitled 'Bitcoin: A Peer-to-Peer +Electronic Cash System' published by 'Satoshi Nakamoto' in October 2008. + +"BSV Blockchain" means: + + (a) the Bitcoin blockchain containing block height #556767 with the hash + "000000000000000001d956714215d96ffc00e0afda4cd0a96c96f8d802b1662b" and + that contains the longest honest persistent chain of blocks which has been + produced in a manner which is consistent with the rules set forth in the + Network Access Rules; and + (b) the test blockchains that contain the longest honest persistent chains of + blocks which has been produced in a manner which is consistent with the + rules set forth in the Network Access Rules. + +"Network Access Rules" or "Rules" means the set of rules regulating the +relationship between BSV Association and the nodes on BSV based on the Bitcoin +Protocol rules and those set out in the Bitcoin White Paper, and available here +https://bsvblockchain.org/network-access-rules. + +"Software" means the software the subject of this license, including any/all +intellectual property rights therein and associated documentation files. + +BSV Association grants permission, free of charge and on a non-exclusive basis +to any person obtaining a copy of the Software to deal in the Software, including +without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to and conditioned upon the following +conditions: + +1 - The text "© BSV Association", and this license shall be included in all +copies or substantial portions of the Software. + +2 - The Software, and any software that is derived from the Software or parts +thereof, may only be used exclusively on the BSV Blockchain. + +For the avoidance of doubt, this license is granted subject to and conditioned +upon your compliance with these terms only and is limited to uses on the BSV +Blockchain. Any exercise of rights not compliant with these terms including +use not for the BSV Blockchain is deemed outside the scope of the license. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES REGARDING ENTITLEMENT, +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS THEREOF BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/packages/content/lch/README.md b/packages/content/lch/README.md new file mode 100644 index 000000000..25fd792a3 --- /dev/null +++ b/packages/content/lch/README.md @@ -0,0 +1,100 @@ +# @bsv/lch + +Reference implementation of [BRC-170](https://bsv.brc.dev/apps/0170), the Licensed Content Header protocol. It provides deterministic CBOR and object identifiers, `.lch` framing, segmented AES-256-GCM, BRC-77 signatures, BRC-78 key delivery, UHRP/CHIRP-aware content adapters, policy/profile checks, multilateral output matching, authority revocation validation, license storage, and whole-placement composition. + +The package keeps acquisition explicit. Inspecting or opening a header never spends money. Applications call preflight, quote, wallet payment, Payee delivery, completion, and recovery as separate steps. `LCHMultipayBuyer.createPayment` is the explicit transaction boundary; it delegates to `createMultipayTransaction`, which invokes the buyer wallet's `createAction`. + +## Install + +```bash +npm install @bsv/lch @bsv/sdk +# Optional for chirp: ciphertext locators: +npm install @bsv/chirp +``` + +## Getting started + +```ts +import { LCHPublisher, LCHReader, MemoryContentSink, WalletBRC77Signer } from '@bsv/lch' + +const signer = await WalletBRC77Signer.create({ wallet }) +const storage = new MemoryContentSink() +const publisher = new LCHPublisher(signer) +const protectedAsset = await publisher.protect(bytes, { + mediaType: 'audio/wav', + name: 'loop.wav', + rights: [ + { interest: 'sound-recording', holder: { name: 'Creator' }, controller: signer.identityKey } + ], + sink: storage +}) + +// An Offer is created after the Asset ID is known, then included in acquisition. +const published = await publisher.publish(protectedAsset, [{ mode: 'inline', offer }], false) +const reader = new LCHReader(storage) +const inspected = await reader.inspect(published.bytes) +const plaintext = await reader.decrypt(inspected, protectedAsset.keys) +``` + +## Acquisition and wallets + +The typed client-side builders are `LCHBuyer`, `LCHMultipayBuyer`, `LCHHttpAcquisitionClient`, `validateQuote`, `createMultipayTransaction`, and `WalletBRC78KeyDelivery`. A player first builds and signs a License Request, preflights it, validates the signed Quote and each embedded Demand, and shows the exact total and split. After an explicit confirmation it creates one multilateral wallet transaction, obtains one profile-valid settlement proof per Demand, completes issuance, and verifies recovery of the resulting License. + +`LCHMultipayBuyer` splits the irreversible and retryable stages deliberately. Quote preparation obtains a short-lived signed Payment Readiness from every Payee, and `refreshReadiness` renews those leases before an explicit wallet confirmation. `createPayment` refuses missing or expired readiness and returns the finalized Atomic BEEF and every signed Delivery immediately after `createAction`; persist that value before network delivery. “Finalized” means signed transaction bytes exist—it does not by itself claim broadcast, processor acceptance, or mining. Call `settleDelivery` for each Payee, retain the returned Receipt or authorized-output evidence, then call `complete` with both proof arrays. After an ambiguous failure, expose the transaction as pending settlement and retry those methods with the same funded payment—never call `createPayment` again for that Quote. + +The Offer endpoint coordinates Quote, completion, and License recovery. Each Payment Demand carries its own Payee-selected endpoint and explicit settlement profile. `#receipt-complete-v1` is the baseline: the Payee must internalize its output and sign a Receipt before License issuance. `#authorized-output-v1` is an opt-in availability profile. Before payment, the Payee signs the exact BRC-29 suffix and locking script plus a transaction-evidence provider and durable Delivery provider. The buyer independently derives and compares that script before `createAction`. `settleDelivery` attempts ordinary Payee delivery first and, only for an authorized-output Demand, obtains signed processor acceptance and a signed retention acknowledgement when direct delivery fails. `collectAuthorizedOutputEvidence` exposes that fallback step separately for recovery orchestration. The issuer can release the License only after the complete bundle verifies. The Payee can later retrieve that exact signed Delivery and internalize it idempotently. + +Those endpoints can be different origins, processes, operators, and wallet substrates; the issuer never becomes a payment proxy merely because it assembled the Quote. Silence, finalized Atomic BEEF, broadcast submission, or an unsigned storage response never satisfies either profile. Authorized-output settlement deliberately delegates availability and acceptance judgment to the identities named by the Payee, makes the exact destination more linkable, and may release keys before Payee-wallet internalization or mining. Use receipt-complete when those tradeoffs are unacceptable. An unavailable fallback provider leaves the existing transaction pending rather than enabling a weaker proof or a second payment. + +`LCHAcquisitionTransport` is the injectable client boundary. Its default is `LCHHttpAcquisitionClient`; a message-box adapter can implement the same methods while preserving the signed objects, per-Demand routing, response authentication, persistence-before-fan-out rule, and idempotent recovery. Native asynchronous wire semantics remain profile work rather than hidden behavior in the core objects. + +The receiving side uses `LCHPayee`, `WalletPaymentReceiver`, and `LCHHttpServer`. `WalletPaymentReceiver` verifies the buyer signature, Demand binding, recovery deadline, exact amount, and BRC-29-derived locking script. It then invokes the receiving Payee wallet directly: + +```ts +const receiver = new WalletPaymentReceiver({ + wallet: payeeWallet, + signer: payeeSigner, + ledger: durablePaymentLedger +}) + +const receipt = await receiver.receive(signedDemand, signedDelivery) +``` + +The wallet call uses BRC-100 `internalizeAction` with the `wallet payment` protocol and exact BRC-29 remittance. The issuer has no implicit custody role: value goes to each identity named in the Payment Demands. The `PaymentLedger` interface makes redelivery idempotent and rejects a conflicting transaction for an already claimed Demand; horizontally scaled servers must back it with an atomic durable store. + +`LCHHttpServer` is a standard Fetch `Request`/`Response` handler, so issuer, Payee, evidence-provider, and Delivery-provider handlers can be mounted independently in Node, edge, serverless, message-box gateways, or tests without framework coupling. Its deterministic-CBOR message types cover License Request preflight, quote, Payment Demand readiness and authorization, direct Payment Delivery, transaction evidence, durable store and authenticated Payee retrieval, Payment Completion, and License recovery. `WalletAuthorizedOutputPayee`, `LCHSettlementService`, and the validation functions expose the same boundaries without HTTP coupling. + +The executable creator/server/player example, connected-wallet module contract, CHIRP/UHRP storage substitutions, container build, and durable deployment topology are in [`apps/lch-reference`](../../../apps/lch-reference/README.md). The [production CHIRP and LCH guide](https://github.com/bsv-blockchain/ts-stack/blob/main/docs/guides/chirp-lch-production.md) adds end-to-end integration code, role ownership, persistence, recovery, security, observability, rollout, and an agent implementation contract. + +The 0.1 publisher and reader accept bounded `Uint8Array` representations. +`UniversalContentSource` defaults to a 512 MiB maximum, and the complete +`resolve()`/`decrypt()` path assembles ciphertext in memory. CHIRP itself can +stream verified ranges, but exposing LCH plaintext progressively requires a +segment-aware adapter that authenticates complete encryption records and +enforces the licensed selection; that adapter is outside the 0.1 API. Configure +an explicit application limit and do not treat raw CHIRP ciphertext chunks as +authenticated plaintext. + +Application-specific catalogue, streaming index, royalty weighting, waveform, timeline, and social metadata belong in non-critical application data or separately registered profiles. The v1 whole-placement resolver supports repeats and arbitrary editorial transforms conservatively: any nonempty derivative selection activates the ingredient's complete declared source selection. Edit metadata does not alter permission or settlement semantics. A future mapping profile is needed only for deterministic selective mapping; an unknown mapping fails closed. + +Server-side HTTPS resolution must provide an endpoint policy with a +public-address DNS resolver and an address-pinning connector. Browser +applications should use an equivalently constrained authenticated gateway +rather than treating a preflight DNS lookup as protection against rebinding. + +## Production integration gate + +Before enabling real purchases, replace fixture wallets, memory content and +license stores, in-process issuer state, Payee ledgers, evidence claims, and +Delivery retention with durable role-scoped implementations. Persist the +funded transaction before fan-out, test recovery by Request ID, keep every +Payee independently routable, pin server connections against DNS rebinding, +and retain signed state through `recoveryUntil`. The complete checklist and +failure matrix are in the [production guide](https://github.com/bsv-blockchain/ts-stack/blob/main/docs/guides/chirp-lch-production.md). + +See [BRC-170](https://bsv.brc.dev/apps/0170) for the normative protocol. If this implementation and the BRC differ, the BRC is authoritative. + +## License + +This package is licensed under the [Open BSV License Version 6](./LICENSE.txt). +The npm artifact also carries a scoped [third-party notice](./THIRD_PARTY_NOTICES.md). The package incorporates no third-party source; its SDK and optional CHIRP peers retain their own license payloads. diff --git a/packages/content/lch/THIRD_PARTY_NOTICES.md b/packages/content/lch/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..5e9565254 --- /dev/null +++ b/packages/content/lch/THIRD_PARTY_NOTICES.md @@ -0,0 +1,23 @@ + + +# Third-Party Notices + +The Open BSV License Version 6 in `LICENSE.txt` applies to current TS Stack +first-party contributions. It does not replace, narrow, or relicense historical +or third-party material identified below. Each identified portion remains available +under its stated terms. + +Distributors must keep this file and the referenced `LICENSES/` files with source, +npm tarballs, browser bundles, WebAssembly artifacts, and container images that +contain the corresponding material. Ordinary dependency licenses remain with those +dependencies and are additionally inventoried in release SBOMs. + +Registry: `governance/third-party-materials.json` + +## Release clearance status + +The notices below reduce attribution risk but do not create rights. A release is +blocked while any item marked `required` remains unresolved. + +- **2026-stack-license-uniformization-authority — cleared:** The uniformization changed 107 total paths, including 55 license or policy texts; all 28 preexisting license/policy files are inventoried and every prior grant remains scoped to its snapshot code, so no blanket retroactive relicensing authority is relied upon. + Accepted evidence: governance/license-continuity.json, governance/license-evidence/pre-uniformization-root-policy.md, and the nine hash-pinned historical Open BSV texts. diff --git a/packages/content/lch/browser-budget.json b/packages/content/lch/browser-budget.json new file mode 100644 index 000000000..1eeb59cfd --- /dev/null +++ b/packages/content/lch/browser-budget.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "profile": "browser", + "package": "@bsv/lch", + "entry": ".", + "requiredExports": [ + "LCHPublisher", + "LCHReader", + "LCHIssuer", + "LCHComposer", + "parseLCH", + "encryptSegmented" + ], + "prohibitedExports": [], + "maximumBytes": { + "vite": { "raw": 550000, "gzip": 145000, "brotli": 125000 }, + "esbuild": { "raw": 430000, "gzip": 135000, "brotli": 118000 } + } +} diff --git a/packages/content/lch/jest.config.js b/packages/content/lch/jest.config.js new file mode 100644 index 000000000..8793b03ed --- /dev/null +++ b/packages/content/lch/jest.config.js @@ -0,0 +1,16 @@ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + transform: { + '^.+\\.ts$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }] + }, + testMatch: ['/test/**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts'], + coverageThreshold: { + global: { branches: 80, functions: 80, lines: 85, statements: 85 } + } +} diff --git a/packages/content/lch/package.json b/packages/content/lch/package.json new file mode 100644 index 000000000..a7e54e023 --- /dev/null +++ b/packages/content/lch/package.json @@ -0,0 +1,86 @@ +{ + "name": "@bsv/lch", + "version": "0.1.0", + "description": "BRC-170 Licensed Content Header reference implementation", + "author": "BSV Association", + "license": "SEE LICENSE IN LICENSE.txt", + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "bin": { + "lch": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE.txt", + "THIRD_PARTY_NOTICES.md" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/content/lch/**/*.{json,md,ts}\"", + "lint": "oxlint src test --deny-warnings", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false", + "test:property": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false test/cbor.property.test.ts", + "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --runInBand --watchman=false", + "vectors:update": "pnpm build && node scripts/regenerate-brc170-vectors.mjs", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes esm --exports LCHPublisher,LCHReader,LCHIssuer,LCHComposer,LCHMultipayBuyer,LCHHttpServer,WalletPaymentReceiver,encodeDeterministicCbor,parseLCH,encryptSegmented --bin lch --bin-args --help", + "prepublishOnly": "pnpm build" + }, + "peerDependencies": { + "@bsv/chirp": "^0.1.0", + "@bsv/sdk": "^2.4.1" + }, + "peerDependenciesMeta": { + "@bsv/chirp": { + "optional": true + } + }, + "devDependencies": { + "@bsv/chirp": "workspace:^", + "@bsv/sdk": "workspace:^", + "@jest/globals": "^30.4.1", + "@types/jest": "^30.0.0", + "@types/node": "^26.1.2", + "@typescript/native": "npm:typescript@7.0.2", + "fast-check": "^4.9.0", + "jest": "^30.4.2", + "oxlint": "^1.76.0", + "ts-jest": "^29.4.12", + "typescript": "npm:@typescript/typescript6@6.0.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bsv-blockchain/ts-stack.git", + "directory": "packages/content/lch" + }, + "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch#readme", + "bugs": { + "url": "https://github.com/bsv-blockchain/ts-stack/issues" + }, + "keywords": [ + "bsv", + "brc-170", + "lch", + "licensed-content", + "odrl", + "c2pa" + ] +} diff --git a/packages/content/lch/scripts/regenerate-brc170-vectors.mjs b/packages/content/lch/scripts/regenerate-brc170-vectors.mjs new file mode 100644 index 000000000..dffef115a --- /dev/null +++ b/packages/content/lch/scripts/regenerate-brc170-vectors.mjs @@ -0,0 +1,640 @@ +import { readFile, writeFile } from 'node:fs/promises' +import process from 'node:process' +import { LockingScript, PrivateKey, ProtoWallet, Transaction } from '@bsv/sdk' +import { + PublicBRC77Verifier, + WalletBRC78KeyDelivery, + WalletBRC77Signer, + encodeDeterministicCbor, + frameLCH, + fromBase64Url, + fromHex, + objectId, + objectIri, + objectPreimage, + parseLCH, + recoveryUntil, + sha256, + toBase64Url, + toHex, + uint64be, + validateCompositionRecord, + verifySignedObject +} from '../dist/index.js' + +const path = process.argv.slice(2).find(argument => argument !== '--') +if (path === undefined) { + throw new Error('Usage: pnpm --filter @bsv/lch vectors:update -- <0170-conformance-vectors.json>') +} + +const source = JSON.parse(await readFile(path, 'utf8')) +const sdkManifest = JSON.parse( + await readFile(new URL('../../../sdk/package.json', import.meta.url), 'utf8') +) +const lchManifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) +const vectors = inflate(source) +const originalEmbedded = fromHex(vectors.framing.embeddedFileHex) +const originalCiphertext = parseLCH(originalEmbedded).ciphertext +if (originalCiphertext === undefined) throw new Error('Vector embedded file has no ciphertext') + +let signatureSequence = 0 +const signer = async value => + WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(value)), + random: length => { + signatureSequence += 1 + return Uint8Array.from( + { length }, + (_, index) => (value * 41 + signatureSequence * 17 + index) & 0xff + ) + } + }) +const publisher = await signer(1) +const buyer = await signer(2) +const composer = await signer(3) +const replacements = [] +const receiptCompleteProfile = 'https://bsv.brc.dev/apps/0170#receipt-complete-v1' +const authorizedOutputProfile = 'https://bsv.brc.dev/apps/0170#authorized-output-v1' +const acceptancePolicy = 'https://bsv.brc.dev/apps/0170#signed-processor-acceptance-v1' + +async function updateRecord(record, type, objectSigner) { + const oldHex = record.idHex + const id = await objectId(type, record.body) + const idHex = toHex(id) + record.type = type + record.deterministicCborHex = toHex(encodeDeterministicCbor(record.body)) + record.idHex = idHex + record.iri = await objectIri(type, record.body) + if (objectSigner !== undefined) { + record.signed = { + body: record.body, + signatures: [await objectSigner.sign(objectPreimage(type, record.body))] + } + await verifySignedObject( + type, + record.signed, + new PublicBRC77Verifier(), + objectSigner.identityKey + ) + } + if (oldHex !== undefined && oldHex !== idHex) replacements.push([oldHex, idHex]) + return id +} + +await updateRecord(vectors.objects.asset, 'asset') +await updateRecord(vectors.objects.authority, 'authority', composer) + +vectors.objects.offer.body.payment.recoveryPeriodSeconds = 86_400 +const offerId = await updateRecord(vectors.objects.offer, 'offer', publisher) + +vectors.objects.licenseRequest.body.offerId = offerId +const requestId = await updateRecord(vectors.objects.licenseRequest, 'license-request', buyer) + +const recovery = recoveryUntil(vectors.objects.quote.body.expiresAt, 86_400) +const demandIds = [] +for (const [index, demand] of vectors.objects.paymentDemands.entries()) { + demand.body.offerId = offerId + demand.body.requestId = requestId + demand.body.buyer = buyer.identityKey + demand.body.recoveryUntil = recovery + demand.body.settlementProfile = receiptCompleteProfile + demandIds.push(await updateRecord(demand, 'payment-demand', index === 0 ? publisher : composer)) +} + +vectors.objects.paymentReadiness = [] +for (const [index, demand] of vectors.objects.paymentDemands.entries()) { + const readiness = { + body: { + version: 1, + demandId: demandIds[index], + requestId, + payee: demand.body.payee, + buyer: buyer.identityKey, + issuedAt: demand.body.expiresAt - 120, + readyUntil: demand.body.expiresAt - 60, + recoveryUntil: recovery + } + } + await updateRecord(readiness, 'payment-readiness', index === 0 ? publisher : composer) + vectors.objects.paymentReadiness.push(readiness) +} + +vectors.objects.quote.body.offerId = offerId +vectors.objects.quote.body.requestId = requestId +vectors.objects.quote.body.recoveryUntil = recovery +vectors.objects.quote.body.demands = vectors.objects.paymentDemands.map(demand => demand.signed) +await updateRecord(vectors.objects.quote, 'quote', publisher) + +const transaction = new Transaction( + 1, + [], + vectors.multilateralPayment.outputs.map(output => ({ + satoshis: output.satoshis, + lockingScript: LockingScript.fromHex(output.lockingScriptHex) + })) +) +const atomicBeef = Uint8Array.from(transaction.toAtomicBEEF(true)) +const transactionIdHex = transaction.id('hex') +vectors.multilateralPayment.atomicBeefHex = toHex(atomicBeef) +vectors.multilateralPayment.syntheticTxidHex = transactionIdHex +vectors.multilateralPayment.note = + 'The input-free Atomic BEEF is an executable transaction fixture; its signed Demand, Delivery, and Receipt bindings are normative vector values.' + +vectors.objects.paymentDeliveries = [] +for (const index of vectors.objects.paymentDemands.keys()) { + const output = vectors.multilateralPayment.outputs[index] + const delivery = { + body: { + version: 1, + demandId: demandIds[index], + requestId, + buyer: buyer.identityKey, + atomicBeef, + outputIndex: output.outputIndex, + derivationPrefix: fromBase64Url(output.derivationPrefixBase64url), + derivationSuffix: fromBase64Url(output.derivationSuffixBase64url) + } + } + await updateRecord(delivery, 'payment-delivery', buyer) + vectors.objects.paymentDeliveries.push(delivery) +} + +const receiptIds = [] +for (const [index, receipt] of vectors.objects.paymentReceipts.entries()) { + receipt.body.demandId = demandIds[index] + receipt.body.requestId = requestId + receipt.body.txid = fromHex(transactionIdHex) + receiptIds.push( + await updateRecord(receipt, 'payment-receipt', index === 0 ? publisher : composer) + ) +} + +vectors.objects.license.body.offerId = offerId +vectors.objects.license.body.requestId = requestId +for (const [index, fulfillment] of vectors.objects.license.body.fulfillments.entries()) { + fulfillment.settlementProfile = receiptCompleteProfile + fulfillment.receiptIds = [receiptIds[index]] +} +const licenseId = await updateRecord(vectors.objects.license, 'license', publisher) + +const authorizedDemand = { + body: { + ...vectors.objects.paymentDemands[0].body, + settlementProfile: authorizedOutputProfile, + challengeNonce: fromHex('101112131415161718191a1b1c1d1e1f') + } +} +const authorizedDemandId = await updateRecord(authorizedDemand, 'payment-demand', publisher) +const authorizedOutput = vectors.multilateralPayment.outputs[0] +const authorization = { + body: { + version: 1, + settlementProfile: authorizedOutputProfile, + demandId: authorizedDemandId, + requestId, + payee: publisher.identityKey, + buyer: buyer.identityKey, + satoshis: authorizedDemand.body.satoshis, + derivationPrefix: fromBase64Url(authorizedOutput.derivationPrefixBase64url), + derivationSuffix: fromBase64Url(authorizedOutput.derivationSuffixBase64url), + lockingScript: fromHex(authorizedOutput.lockingScriptHex), + authorizedAt: authorizedDemand.body.expiresAt - 120, + authorizedUntil: authorizedDemand.body.expiresAt, + recoveryUntil: recovery, + evidenceProvider: composer.identityKey, + evidenceEndpoint: 'https://processor.example/lch/evidence', + evidencePolicy: acceptancePolicy, + minimumTransactionState: 'accepted', + deliveryProvider: composer.identityKey, + deliveryEndpoint: 'https://availability.example/lch/store', + retrievalEndpoint: 'https://availability.example/lch/retrieve' + } +} +const authorizationId = await updateRecord(authorization, 'payment-authorization', publisher) +const authorizedDelivery = { + body: { + ...vectors.objects.paymentDeliveries[0].body, + demandId: authorizedDemandId, + derivationPrefix: authorization.body.derivationPrefix, + derivationSuffix: authorization.body.derivationSuffix + } +} +const authorizedDeliveryId = await updateRecord(authorizedDelivery, 'payment-delivery', buyer) +const transactionEvidence = { + body: { + version: 1, + authorizationId, + txid: fromHex(transactionIdHex), + provider: composer.identityKey, + state: 'accepted', + policy: acceptancePolicy, + observedAt: authorizedDemand.body.expiresAt - 30 + } +} +await updateRecord(transactionEvidence, 'transaction-evidence', composer) +const deliveryAcknowledgement = { + body: { + version: 1, + authorizationId, + deliveryId: authorizedDeliveryId, + demandId: authorizedDemandId, + requestId, + payee: publisher.identityKey, + provider: composer.identityKey, + storedAt: authorizedDemand.body.expiresAt - 30, + availableUntil: recovery, + retrievalEndpoint: authorization.body.retrievalEndpoint + } +} +await updateRecord(deliveryAcknowledgement, 'payment-delivery-ack', composer) +const deliveryRetrieval = { + body: { + version: 1, + authorizationId, + payee: publisher.identityKey, + requestedAt: authorizedDemand.body.expiresAt + 60, + nonce: fromHex('202122232425262728292a2b2c2d2e2f') + } +} +await updateRecord(deliveryRetrieval, 'payment-delivery-retrieval', publisher) +const authorizedQuote = { + body: { + ...vectors.objects.quote.body, + demands: [authorizedDemand.signed, vectors.objects.paymentDemands[1].signed] + } +} +await updateRecord(authorizedQuote, 'quote', publisher) +const authorizedOutputEvidence = { + authorization: authorization.signed, + delivery: authorizedDelivery.signed, + transactionEvidence: transactionEvidence.signed, + deliveryAcknowledgement: deliveryAcknowledgement.signed +} +const authorizedCompletion = { + request: vectors.objects.licenseRequest.signed, + quote: authorizedQuote.signed, + atomicBeef, + receipts: [vectors.objects.paymentReceipts[1].signed], + authorizedOutputs: [authorizedOutputEvidence] +} +vectors.settlementProfiles = { + receiptComplete: { + identifier: receiptCompleteProfile, + requiredEvidence: 'payee receipt' + }, + authorizedOutput: { + identifier: authorizedOutputProfile, + evidencePolicy: acceptancePolicy, + demand: authorizedDemand, + quote: authorizedQuote, + authorization, + delivery: authorizedDelivery, + transactionEvidence, + deliveryAcknowledgement, + deliveryRetrieval, + storedDelivery: { + authorization: authorization.signed, + delivery: authorizedDelivery.signed, + deliveryAcknowledgement: deliveryAcknowledgement.signed + }, + completionCborHex: toHex(encodeDeterministicCbor(authorizedCompletion)), + expected: { + payeeOfflineAfterReadiness: 'license issuance succeeds with complete bundle', + lateRetrieval: 'same Delivery internalized once; late Receipt does not change License', + wrongScript: 'ERR_LCH_PAYMENT', + wrongAmount: 'ERR_LCH_PAYMENT', + broadcastOnly: 'ERR_LCH_PAYMENT', + wrongEvidenceProvider: 'ERR_LCH_PAYMENT', + insufficientRetention: 'ERR_LCH_DELIVERY', + deliveryProviderUnavailable: 'pending settlement', + conflictingAcceptedTransaction: 'ERR_LCH_PAYMENT', + bundleForReceiptCompleteDemand: 'ERR_LCH_PROFILE_UNSUPPORTED' + } + } +} + +const completion = { + request: vectors.objects.licenseRequest.signed, + quote: vectors.objects.quote.signed, + atomicBeef, + receipts: vectors.objects.paymentReceipts.map(receipt => receipt.signed) +} +vectors.httpBinding = { + mediaType: 'application/vnd.bsv.lch+cbor', + operations: { + licenseRequestPreflight: { + requestType: 'license-request-preflight', + successStatus: 204, + payloadCborHex: toHex(encodeDeterministicCbor(vectors.objects.licenseRequest.signed)) + }, + quote: { + requestType: 'license-request', + responseType: 'quote', + successStatus: 200 + }, + paymentDemandPreflight: { + requestType: 'payment-demand', + responseType: 'payment-readiness', + successStatus: 200, + responseCborHex: toHex(encodeDeterministicCbor(vectors.objects.paymentReadiness[0].signed)) + }, + paymentAuthorization: { + requestType: 'payment-authorization-request', + responseType: 'payment-authorization', + successStatus: 200, + requestCborHex: toHex(encodeDeterministicCbor(authorizedDemand.signed)), + responseCborHex: toHex(encodeDeterministicCbor(authorization.signed)) + }, + paymentDelivery: { + requestType: 'payment-delivery', + responseType: 'payment-receipt', + successStatus: 200, + payloadCborHex: toHex(encodeDeterministicCbor(vectors.objects.paymentDeliveries[0].signed)) + }, + paymentDeliveryStore: { + requestType: 'payment-delivery-store', + responseType: 'payment-delivery-ack', + successStatus: 200, + payloadCborHex: toHex( + encodeDeterministicCbor({ + authorization: authorization.signed, + delivery: authorizedDelivery.signed + }) + ) + }, + paymentDeliveryRetrieval: { + requestType: 'payment-delivery-retrieval', + responseType: 'payment-delivery-stored', + successStatus: 200, + absentStatus: 404, + payloadCborHex: toHex(encodeDeterministicCbor(deliveryRetrieval.signed)) + }, + transactionEvidence: { + requestType: 'transaction-evidence-request', + responseType: 'transaction-evidence', + successStatus: 200, + payloadCborHex: toHex( + encodeDeterministicCbor({ authorization: authorization.signed, atomicBeef }) + ) + }, + paymentCompletion: { + requestType: 'payment-completion', + responseType: 'license', + successStatus: 200, + payloadCborHex: toHex(encodeDeterministicCbor(completion)) + }, + authorizedOutputCompletion: { + requestType: 'payment-completion', + responseType: 'license', + successStatus: 200, + payloadCborHex: toHex(encodeDeterministicCbor(authorizedCompletion)) + }, + licenseRecovery: { + requestType: 'license-recovery', + responseType: 'license', + successStatus: 200, + absentStatus: 404, + payloadCborHex: toHex(encodeDeterministicCbor({ requestId })) + } + }, + error: { + responseType: 'error', + payloadCborHex: toHex(encodeDeterministicCbor({ code: 'ERR_LCH_PAYMENT' })) + } +} + +const ingredient = vectors.objects.compositionRecord.body.ingredients[0] +ingredient.sourceLicenseId = licenseId +ingredient.settlementReceiptIds = receiptIds +ingredient.mappingProfile = 'https://bsv.brc.dev/apps/0170#whole-placement-v1' +await updateRecord(vectors.objects.compositionRecord, 'composition-record') + +vectors.objects.derivedAsset.body.composition = vectors.objects.compositionRecord.body +await updateRecord(vectors.objects.derivedAsset, 'asset') + +vectors.c2pa.compositionRecord = vectors.objects.compositionRecord +vectors.c2pa.derivedAsset = vectors.objects.derivedAsset + +vectors.objects.header.body.acquisition[0].offer = vectors.objects.offer.signed +vectors.objects.header.body.authority = [vectors.objects.authority.signed] +const headerBody = vectors.objects.header.body +const oldHeaderHex = vectors.objects.header.idHex +const headerId = await objectId('header', headerBody) +vectors.objects.header.deterministicCborHex = toHex(encodeDeterministicCbor(headerBody)) +vectors.objects.header.idHex = toHex(headerId) +vectors.objects.header.iri = await objectIri('header', headerBody) +if (oldHeaderHex !== vectors.objects.header.idHex) { + replacements.push([oldHeaderHex, vectors.objects.header.idHex]) +} +const headerSignature = await publisher.sign(objectPreimage('header', headerBody)) +vectors.objects.header.signedHeader = { ...headerBody, signatures: [headerSignature] } +const signedHeaderBytes = encodeDeterministicCbor(vectors.objects.header.signedHeader) +vectors.objects.header.signedHeaderCborHex = toHex(signedHeaderBytes) +vectors.objects.header.signedHeaderLength = signedHeaderBytes.length + +const embedded = frameLCH(vectors.objects.header.signedHeader, originalCiphertext) +const detached = frameLCH(vectors.objects.header.signedHeader) +vectors.framing.headerCborHex = toHex(signedHeaderBytes) +vectors.framing.headerLengthUint64beHex = toHex(uint64be(signedHeaderBytes.length)) +vectors.framing.prefixWithoutPayloadHex = toHex(detached) +vectors.framing.embeddedFileHex = toHex(embedded) +vectors.framing.embeddedFileSha256Hex = toHex(await sha256(embedded)) +vectors.framing.detachedHeaderSha256Hex = toHex(await sha256(detached)) + +vectors.brc77.offerSignatureHex = toHex(vectors.objects.offer.signed.signatures[0]) +vectors.brc77.headerSignatureHex = toHex(headerSignature) +vectors.brc77.licenseSignatureHex = toHex(vectors.objects.license.signed.signatures[0]) +vectors.multilateralPayment.outputs.forEach((output, index) => { + output.demandIdHex = toHex(demandIds[index]) +}) +for (const [index, output] of vectors.multilateralPayment.outputs.entries()) { + const keyID = `${output.derivationPrefixBase64url} ${output.derivationSuffixBase64url}` + const wallet = new ProtoWallet(new PrivateKey(index === 0 ? 1 : 3)) + const { publicKey } = await wallet.getPublicKey({ + protocolID: [2, '3241645161d8'], + keyID, + counterparty: toHex(buyer.identityKey), + forSelf: true + }) + if (publicKey !== output.derivedPublicKeyHex) { + throw new Error(`Payee ${index} forSelf BRC-29 derivation does not match its payment output`) + } + output.receiverDerivation = { + counterpartyIdentityKeyHex: toHex(buyer.identityKey), + forSelf: true, + derivedPublicKeyHex: publicKey + } +} + +const editorialTransforms = [ + { placement: 1, kind: 'identity' }, + { placement: 2, kind: 'identity', repeatOf: 1 }, + { placement: 3, kind: 'reverse' }, + { placement: 4, kind: 'time-warp', rate: { numerator: 1, denominator: 2 } }, + { placement: 5, kind: 'time-warp', rate: { numerator: 2, denominator: 1 } }, + { placement: 6, kind: 'distortion', amount: 4 } +] +const editorialSourceAssetId = await objectId('asset', vectors.objects.asset.body) +vectors.editorialComposition = { + body: { + version: 1, + c2paManifestDigest: await sha256(new TextEncoder().encode('editorial-edge-cases')), + ingredients: await Promise.all( + editorialTransforms.map(async transform => ({ + sourceAssetId: editorialSourceAssetId, + sourceLicenseId: licenseId, + c2paIngredient: { + url: `self#jumbf=/c2pa/editorial/c2pa.assertions/c2pa.ingredient.v3/${transform.placement}`, + alg: 'sha256', + hash: await sha256(new TextEncoder().encode(`editorial:${transform.placement}`)) + }, + relationship: 'componentOf', + sourceSelection: { type: 'all' }, + derivedSelection: { type: 'all' }, + mappingProfile: 'https://bsv.brc.dev/apps/0170#whole-placement-v1', + metadata: { + 'https://example.invalid/lch-reference/edit-v1': transform + } + })) + ) + } +} +validateCompositionRecord(vectors.editorialComposition.body) +await updateRecord(vectors.editorialComposition, 'composition-record') + +vectors.reviewCorrections = { + paymentRecovery: { + recoveryPeriodSeconds: 86_400, + expiresAt: vectors.objects.quote.body.expiresAt, + recoveryUntil: recovery, + validRecoveryAt: BigInt(recovery) - 1n, + rejectNewTransactionAt: BigInt(vectors.objects.quote.body.expiresAt) + 1n, + exactRedelivery: 'idempotent' + }, + randomizedOutputs: { + finalizedOrder: [ + vectors.multilateralPayment.outputs[1].lockingScriptHex, + 'unrelated-output', + vectors.multilateralPayment.outputs[0].lockingScriptHex + ], + expectedDemandOutputIndices: [2, 0], + missing: 'ERR_LCH_PAYMENT', + duplicate: 'ERR_LCH_PAYMENT', + ambiguous: 'ERR_LCH_PAYMENT' + }, + revocation: [ + { status: 'unspent', ageSeconds: 30, expected: 'valid' }, + { status: 'unspent', ageSeconds: 86_401, expected: 'ERR_LCH_REVOCATION' }, + { status: 'spent-mempool', ageSeconds: 1, expected: 'ERR_LCH_REVOCATION' }, + { status: 'spent-confirmed', ageSeconds: 1, expected: 'ERR_LCH_REVOCATION' }, + { status: 'unknown', ageSeconds: 1, expected: 'ERR_LCH_REVOCATION' }, + { + status: 'unspent', + ageSeconds: 1, + reorganizationAffected: true, + expected: 'ERR_LCH_REVOCATION' + } + ], + endpointTrust: { + accepted: ['https://content.example/lch/object'], + rejected: [ + 'http://content.example/lch/object', + 'https://127.0.0.1/object', + 'https://user@example.com/object#fragment' + ], + identityRedirect: { status: 307, sameOrigin: true, expected: 'valid' }, + crossOriginIdentityRedirect: 'ERR_LCH_ENDPOINT' + }, + selfReference: { + topLevelUid: 'virtual substitution', + nestedLiteral: 'unchanged', + transmittedBytes: 'unchanged' + }, + compositionMapping: { + supported: 'https://bsv.brc.dev/apps/0170#whole-placement-v1', + derivedSelection: { type: 'all' }, + repeatedPlacements: 'distinct ingredients', + editorialCompositionId: vectors.editorialComposition.idHex, + editorialTransforms, + editorialEffect: 'descriptive metadata; complete sourceSelection remains active', + duplicateC2paBinding: 'ERR_LCH_PROVENANCE', + unknownMapping: 'ERR_LCH_PROFILE_UNSUPPORTED' + }, + timeBoundaries: { + notBefore: { exact: 'active', oneSecondBefore: 'not-started' }, + notAfter: { oneSecondBefore: 'active', exact: 'expired' }, + expiresAt: { oneSecondBefore: 'new transaction allowed', exact: 'new transaction rejected' }, + recoveryUntil: { oneSecondBefore: 'recovery allowed', exact: 'recovery rejected' } + }, + keyPeriodCoverage: { + wholeAssetKeyIds: vectors.segmentedEncryption.descriptor.keyPeriods.map(period => period.keyId), + selectedSegments: { type: 'segments', ranges: [[2, 4]] }, + selectedKeyIds: [vectors.segmentedEncryption.descriptor.keyPeriods[1].keyId], + missing: 'ERR_LCH_KEY', + duplicate: 'ERR_LCH_KEY', + outOfSelection: 'ERR_LCH_KEY' + }, + training: { + actionAloneRequiresComposition: false, + claimedIndividualSource: 'inputTo', + datasetRootProfile: 'unsupported in v1' + } +} + +const canonicalBrc78 = fromHex(vectors.brc78.serializedMessageHex) +canonicalBrc78.set(Uint8Array.of(0x42, 0x42, 0x10, 0x33), 0) +vectors.brc78.serializedVersionHex = toHex(canonicalBrc78.slice(0, 4)) +vectors.brc78.serializedMessageHex = toHex(canonicalBrc78) +const recoveredBrc78 = await new WalletBRC78KeyDelivery(new ProtoWallet(new PrivateKey(2))).recover( + canonicalBrc78 +) +if ( + toHex(recoveredBrc78.keyId) !== vectors.brc78.expectedRecoveredKeyIdHex || + toHex(recoveredBrc78.cek) !== vectors.brc78.expectedRecoveredCekHex +) { + throw new Error('BRC-78 vector does not recover its expected LCH key grant') +} +vectors.brc78.verifiedWithBsvSdk = true + +vectors.generatedWith = { + node: process.version, + bsvSdk: sdkManifest.version, + lch: lchManifest.version, + deterministicCbor: '@bsv/lch' +} + +let serialized = JSON.stringify(deflate(vectors), null, 2) +for (const [oldHex, newHex] of replacements) { + const oldBytes = fromHex(oldHex) + const newBytes = fromHex(newHex) + serialized = serialized + .replaceAll(oldHex, newHex) + .replaceAll(toBase64Url(oldBytes), toBase64Url(newBytes)) +} +await writeFile(path, `${serialized}\n`) + +function inflate(value) { + if (Array.isArray(value)) return value.map(inflate) + if (value !== null && typeof value === 'object') { + if (Object.keys(value).length === 1 && typeof value.$bytes === 'string') { + return fromBase64Url(value.$bytes) + } + if (Object.keys(value).length === 1 && typeof value.$uint === 'string') { + return BigInt(value.$uint) + } + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, inflate(item)])) + } + return value +} + +function deflate(value) { + if (value instanceof Uint8Array) return { $bytes: toBase64Url(value) } + if (typeof value === 'bigint') { + return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : { $uint: value.toString() } + } + if (Array.isArray(value)) return value.map(deflate) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, deflate(item)])) + } + return value +} diff --git a/packages/content/lch/src/acquisition.ts b/packages/content/lch/src/acquisition.ts new file mode 100644 index 000000000..7bee17451 --- /dev/null +++ b/packages/content/lch/src/acquisition.ts @@ -0,0 +1,748 @@ +import { lchAssert } from './errors.js' +import { encodeDeterministicCbor } from './cbor.js' +import { objectId, toHex } from './hash.js' +import { signObject, verifySignedObject } from './objects.js' +import { recoveryUntil } from './payment.js' +import { normalizeSelection } from './selection.js' +import { PublicBRC77Verifier } from './signatures.js' +import { LCH_SETTLEMENT_PROFILES } from './constants.js' +import type { + LCHSignatureVerifier, + LCHSigner, + LCHUint, + LCHValue, + Selection, + SignedObject +} from './types.js' +import type { AuthorizedOutputEvidence } from './settlement.js' + +const MAX_UINT64 = 0xffffffffffffffffn + +export interface LicenseRequestOptions { + offerId: Uint8Array + assetId: Uint8Array + buyer?: Uint8Array + action: string + selection: Selection + acceptedPolicyDigest: Uint8Array + acceptedHumanTermDigests?: Uint8Array[] + requestNonce?: Uint8Array + createdAt: LCHUint + mechanismChoices?: Record + critical?: string[] +} + +export interface PaymentDemandOptions { + requestId: Uint8Array + offerId: Uint8Array + dutyUid: string + payee?: Uint8Array + buyer: Uint8Array + endpoint: string + satoshis: LCHUint + derivationPrefix?: Uint8Array + challengeNonce?: Uint8Array + expiresAt: LCHUint + recoveryPeriodSeconds: LCHUint + settlementProfile?: string + critical?: string[] + allowInsecureLocalEndpoint?: boolean +} + +export interface QuoteOptions { + requestId: Uint8Array + offerId: Uint8Array + assetId: Uint8Array + buyer: Uint8Array + selection: Selection + segmentSelection?: Extract + demands: SignedObject[] + expiresAt: LCHUint + recoveryPeriodSeconds: LCHUint + critical?: string[] +} + +export interface PaymentDeliveryOptions { + demandId: Uint8Array + requestId: Uint8Array + buyer?: Uint8Array + atomicBeef: Uint8Array + outputIndex: number + derivationPrefix: Uint8Array + derivationSuffix: Uint8Array + critical?: string[] +} + +export interface PaymentReadinessOptions { + demandId: Uint8Array + requestId: Uint8Array + payee?: Uint8Array + buyer: Uint8Array + issuedAt: LCHUint + readyUntil: LCHUint + recoveryUntil: LCHUint + critical?: string[] +} + +export interface PaymentReceiptOptions { + demandId: Uint8Array + requestId: Uint8Array + payee?: Uint8Array + txid: Uint8Array + outputIndex: number + satoshis: LCHUint + receivedAt: LCHUint + critical?: string[] +} + +export interface PaymentDeliveryRetrievalOptions { + authorizationId: Uint8Array + payee?: Uint8Array + requestedAt: LCHUint + nonce?: Uint8Array + critical?: string[] +} + +export interface PaymentCompletion { + request: SignedObject + quote: SignedObject + atomicBeef: Uint8Array + receipts: SignedObject[] + authorizedOutputs?: AuthorizedOutputEvidence[] +} + +export interface AcquisitionValidationOptions { + allowInsecureLocalOrigins?: readonly string[] +} + +export class LCHBuyer { + constructor( + private readonly signer: LCHSigner, + private readonly random: (length: number) => Uint8Array = secureRandom + ) {} + + async createRequest(options: LicenseRequestOptions): Promise { + bytes(options.offerId, 32, 'Offer ID') + bytes(options.assetId, 32, 'Asset ID') + const buyer = options.buyer ?? this.signer.identityKey + bytes(buyer, 33, 'Buyer identity') + lchAssert( + toHex(buyer) === toHex(this.signer.identityKey), + 'ERR_LCH_SIGNATURE', + 'License Request signer is not the buyer' + ) + nonempty(options.action, 'Requested action') + bytes(options.acceptedPolicyDigest, 32, 'Accepted Policy digest') + for (const digest of options.acceptedHumanTermDigests ?? []) + bytes(digest, 32, 'Accepted human-term digest') + const requestNonce = options.requestNonce ?? this.random(16) + bytes(requestNonce, 16, 'Request nonce') + uint(options.createdAt, 'Request creation time', 'ERR_LCH_LICENSE') + extensions(options.critical) + const body: Record = { + version: 1, + offerId: options.offerId, + assetId: options.assetId, + buyer, + action: options.action, + selection: normalizeSelection(options.selection) as unknown as Record, + acceptedPolicyDigest: options.acceptedPolicyDigest, + ...(options.acceptedHumanTermDigests === undefined + ? {} + : { acceptedHumanTermDigests: options.acceptedHumanTermDigests }), + requestNonce, + createdAt: options.createdAt, + ...(options.mechanismChoices === undefined + ? {} + : { mechanismChoices: options.mechanismChoices }), + ...(options.critical === undefined ? {} : { critical: options.critical }) + } + return signObject('license-request', body, this.signer) + } + + async createPaymentDelivery(options: PaymentDeliveryOptions): Promise { + bytes(options.demandId, 32, 'Demand ID') + bytes(options.requestId, 32, 'Request ID') + const buyer = options.buyer ?? this.signer.identityKey + bytes(buyer, 33, 'Buyer identity') + lchAssert( + toHex(buyer) === toHex(this.signer.identityKey), + 'ERR_LCH_SIGNATURE', + 'Payment Delivery signer is not the buyer' + ) + lchAssert(options.atomicBeef.length > 0, 'ERR_LCH_PAYMENT', 'Atomic BEEF is absent') + outputIndex(options.outputIndex) + bytes(options.derivationPrefix, 32, 'Derivation prefix') + bytes(options.derivationSuffix, 32, 'Derivation suffix') + extensions(options.critical) + return signObject( + 'payment-delivery', + { + version: 1, + demandId: options.demandId, + requestId: options.requestId, + buyer, + atomicBeef: options.atomicBeef, + outputIndex: options.outputIndex, + derivationPrefix: options.derivationPrefix, + derivationSuffix: options.derivationSuffix, + ...(options.critical === undefined ? {} : { critical: options.critical }) + }, + this.signer + ) + } +} + +export class LCHPayee { + constructor( + private readonly signer: LCHSigner, + private readonly random: (length: number) => Uint8Array = secureRandom + ) {} + + async createDemand(options: PaymentDemandOptions): Promise { + bytes(options.requestId, 32, 'Request ID') + bytes(options.offerId, 32, 'Offer ID') + const payee = options.payee ?? this.signer.identityKey + bytes(payee, 33, 'Payee identity') + lchAssert( + toHex(payee) === toHex(this.signer.identityKey), + 'ERR_LCH_SIGNATURE', + 'Payment Demand signer is not the payee' + ) + bytes(options.buyer, 33, 'Demand buyer identity') + nonempty(options.dutyUid, 'Duty UID') + endpoint(options.endpoint, options.allowInsecureLocalEndpoint) + uint(options.satoshis, 'Demand amount', 'ERR_LCH_PAYMENT') + const derivationPrefix = options.derivationPrefix ?? this.random(32) + const challengeNonce = options.challengeNonce ?? this.random(16) + bytes(derivationPrefix, 32, 'Derivation prefix') + bytes(challengeNonce, 16, 'Challenge nonce') + const expiresAt = uint(options.expiresAt, 'Demand expiry', 'ERR_LCH_QUOTE') + const recovery = recoveryUntil(expiresAt, options.recoveryPeriodSeconds) + const settlementProfile = options.settlementProfile ?? LCH_SETTLEMENT_PROFILES.receiptComplete + lchAssert( + settlementProfile === LCH_SETTLEMENT_PROFILES.receiptComplete || + settlementProfile === LCH_SETTLEMENT_PROFILES.authorizedOutput, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Payment Demand settlement profile is unsupported' + ) + extensions(options.critical) + return signObject( + 'payment-demand', + { + version: 1, + requestId: options.requestId, + offerId: options.offerId, + dutyUid: options.dutyUid, + payee, + buyer: options.buyer, + endpoint: options.endpoint, + satoshis: options.satoshis, + derivationPrefix, + challengeNonce, + expiresAt: options.expiresAt, + recoveryUntil: recovery, + settlementProfile, + ...(options.critical === undefined ? {} : { critical: options.critical }) + }, + this.signer + ) + } + + async createReceipt(options: PaymentReceiptOptions): Promise { + bytes(options.demandId, 32, 'Demand ID') + bytes(options.requestId, 32, 'Request ID') + const payee = options.payee ?? this.signer.identityKey + bytes(payee, 33, 'Payee identity') + lchAssert( + toHex(payee) === toHex(this.signer.identityKey), + 'ERR_LCH_SIGNATURE', + 'Payment Receipt signer is not the payee' + ) + bytes(options.txid, 32, 'Transaction ID') + outputIndex(options.outputIndex) + uint(options.satoshis, 'Receipt amount', 'ERR_LCH_PAYMENT') + uint(options.receivedAt, 'Receipt time', 'ERR_LCH_PAYMENT') + extensions(options.critical) + return signObject( + 'payment-receipt', + { + version: 1, + demandId: options.demandId, + requestId: options.requestId, + payee, + txid: options.txid, + outputIndex: options.outputIndex, + satoshis: options.satoshis, + receivedAt: options.receivedAt, + ...(options.critical === undefined ? {} : { critical: options.critical }) + }, + this.signer + ) + } + + async createReadiness(options: PaymentReadinessOptions): Promise { + bytes(options.demandId, 32, 'Demand ID') + bytes(options.requestId, 32, 'Request ID') + const payee = options.payee ?? this.signer.identityKey + bytes(payee, 33, 'Payee identity') + lchAssert( + toHex(payee) === toHex(this.signer.identityKey), + 'ERR_LCH_SIGNATURE', + 'Payment Readiness signer is not the payee' + ) + bytes(options.buyer, 33, 'Buyer identity') + const issuedAt = uint(options.issuedAt, 'Readiness issue time', 'ERR_LCH_PAYMENT') + const readyUntil = uint(options.readyUntil, 'Readiness deadline', 'ERR_LCH_PAYMENT') + const recovery = uint(options.recoveryUntil, 'Recovery deadline', 'ERR_LCH_PAYMENT') + lchAssert( + issuedAt < readyUntil && readyUntil <= recovery, + 'ERR_LCH_PAYMENT', + 'Payment Readiness deadlines are invalid' + ) + extensions(options.critical) + return signObject( + 'payment-readiness', + { + version: 1, + demandId: options.demandId, + requestId: options.requestId, + payee, + buyer: options.buyer, + issuedAt: options.issuedAt, + readyUntil: options.readyUntil, + recoveryUntil: options.recoveryUntil, + ...(options.critical === undefined ? {} : { critical: options.critical }) + }, + this.signer + ) + } + + async createDeliveryRetrieval(options: PaymentDeliveryRetrievalOptions): Promise { + bytes(options.authorizationId, 32, 'Payment Authorization ID') + const payee = options.payee ?? this.signer.identityKey + bytes(payee, 33, 'Payee identity') + lchAssert( + toHex(payee) === toHex(this.signer.identityKey), + 'ERR_LCH_SIGNATURE', + 'Delivery Retrieval signer is not the Payee' + ) + uint(options.requestedAt, 'Delivery retrieval time', 'ERR_LCH_PAYMENT') + const nonce = options.nonce ?? this.random(16) + bytes(nonce, 16, 'Delivery retrieval nonce') + extensions(options.critical) + return signObject( + 'payment-delivery-retrieval', + { + version: 1, + authorizationId: options.authorizationId, + payee, + requestedAt: options.requestedAt, + nonce, + ...(options.critical === undefined ? {} : { critical: options.critical }) + }, + this.signer + ) + } +} + +export class LCHQuoteIssuer { + constructor( + private readonly signer: LCHSigner, + private readonly verifier: LCHSignatureVerifier = new PublicBRC77Verifier() + ) {} + + async createQuote(options: QuoteOptions): Promise { + bytes(options.requestId, 32, 'Request ID') + bytes(options.offerId, 32, 'Offer ID') + bytes(options.assetId, 32, 'Asset ID') + bytes(options.buyer, 33, 'Quote buyer identity') + lchAssert(options.demands.length > 0, 'ERR_LCH_QUOTE', 'Quote has no Payment Demands') + const expiresAt = uint(options.expiresAt, 'Quote expiry', 'ERR_LCH_QUOTE') + const recovery = recoveryUntil(expiresAt, options.recoveryPeriodSeconds) + let total = 0n + const seen = new Set() + for (const demand of options.demands) { + const payee = memberBytes(demand.body, 'payee', 33, 'Demand payee') + await verifySignedObject('payment-demand', demand, this.verifier, payee) + equalId(demand.body.requestId, options.requestId, 'Demand Request ID') + equalId(demand.body.offerId, options.offerId, 'Demand Offer ID') + equalId(demand.body.buyer, options.buyer, 'Demand buyer identity') + lchAssert( + demand.body.settlementProfile === LCH_SETTLEMENT_PROFILES.receiptComplete || + demand.body.settlementProfile === LCH_SETTLEMENT_PROFILES.authorizedOutput, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Payment Demand settlement profile is unsupported' + ) + lchAssert( + uint(demand.body.expiresAt, 'Demand expiry', 'ERR_LCH_QUOTE') === expiresAt && + uint(demand.body.recoveryUntil, 'Demand recovery deadline', 'ERR_LCH_QUOTE') === recovery, + 'ERR_LCH_QUOTE', + 'Demand deadlines do not match the Quote' + ) + const demandId = toHex(await objectId('payment-demand', demand.body)) + lchAssert(!seen.has(demandId), 'ERR_LCH_QUOTE', 'Quote repeats a Payment Demand') + seen.add(demandId) + total += uint(demand.body.satoshis, 'Demand amount', 'ERR_LCH_PAYMENT') + lchAssert(total <= 2_100_000_000_000_000n, 'ERR_LCH_PAYMENT', 'Quote total is out of range') + } + extensions(options.critical) + return signObject( + 'quote', + { + version: 1, + requestId: options.requestId, + offerId: options.offerId, + assetId: options.assetId, + selection: normalizeSelection(options.selection) as unknown as Record, + ...(options.segmentSelection === undefined + ? {} + : { + segmentSelection: normalizeSelection(options.segmentSelection) as unknown as Record< + string, + LCHValue + > + }), + demands: options.demands as unknown as LCHValue[], + totalSatoshis: total, + expiresAt: options.expiresAt, + recoveryUntil: recovery, + ...(options.critical === undefined ? {} : { critical: options.critical }) + }, + this.signer + ) + } +} + +export async function validateLicenseRequest( + request: SignedObject, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier() +): Promise { + const buyer = memberBytes(request.body, 'buyer', 33, 'Buyer identity') + await verifySignedObject('license-request', request, verifier, buyer) + memberBytes(request.body, 'offerId', 32, 'Offer ID') + memberBytes(request.body, 'assetId', 32, 'Asset ID') + nonempty(request.body.action, 'Requested action') + memberBytes(request.body, 'acceptedPolicyDigest', 32, 'Accepted Policy digest') + uint(request.body.createdAt, 'Request creation time', 'ERR_LCH_LICENSE') + selection(request.body.selection) + return objectId('license-request', request.body) +} + +export async function validatePaymentDemand( + demand: SignedObject, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier(), + options: AcquisitionValidationOptions = {} +): Promise { + const payee = memberBytes(demand.body, 'payee', 33, 'Payee identity') + await verifySignedObject('payment-demand', demand, verifier, payee) + memberBytes(demand.body, 'requestId', 32, 'Request ID') + memberBytes(demand.body, 'offerId', 32, 'Offer ID') + memberBytes(demand.body, 'buyer', 33, 'Demand buyer identity') + memberBytes(demand.body, 'derivationPrefix', 32, 'Derivation prefix') + memberBytes(demand.body, 'challengeNonce', 16, 'Challenge nonce') + nonempty(demand.body.dutyUid, 'Duty UID') + const demandEndpoint = demand.body.endpoint + endpoint(demandEndpoint, isAllowedLocalOrigin(demandEndpoint, options.allowInsecureLocalOrigins)) + uint(demand.body.satoshis, 'Demand amount', 'ERR_LCH_PAYMENT') + const expires = uint(demand.body.expiresAt, 'Demand expiry', 'ERR_LCH_QUOTE') + const recovery = uint(demand.body.recoveryUntil, 'Demand recovery deadline', 'ERR_LCH_QUOTE') + lchAssert(expires < recovery, 'ERR_LCH_QUOTE', 'Demand recovery window is empty') + const settlementProfile = demand.body.settlementProfile + lchAssert( + settlementProfile === LCH_SETTLEMENT_PROFILES.receiptComplete || + settlementProfile === LCH_SETTLEMENT_PROFILES.authorizedOutput, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Payment Demand settlement profile is unsupported' + ) + return objectId('payment-demand', demand.body) +} + +export async function validateQuote( + quote: SignedObject, + request: SignedObject, + issuer: Uint8Array, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier(), + options: AcquisitionValidationOptions = {} +): Promise { + bytes(issuer, 33, 'Quote issuer') + const requestId = await validateLicenseRequest(request, verifier) + await verifySignedObject('quote', quote, verifier, issuer) + equalId(quote.body.requestId, requestId, 'Quote Request ID') + const offerId = memberBytes(request.body, 'offerId', 32, 'Request Offer ID') + const assetId = memberBytes(request.body, 'assetId', 32, 'Request Asset ID') + const buyer = memberBytes(request.body, 'buyer', 33, 'Request buyer identity') + equalId(quote.body.offerId, offerId, 'Quote Offer ID') + equalId(quote.body.assetId, assetId, 'Quote Asset ID') + const requestSelection = selection(request.body.selection) + const quoteSelection = selection(quote.body.selection) + lchAssert( + toHex(encodeDeterministicCbor(requestSelection as unknown as LCHValue)) === + toHex(encodeDeterministicCbor(quoteSelection as unknown as LCHValue)), + 'ERR_LCH_SELECTION', + 'Quote selection does not match the License Request' + ) + const demands = quote.body.demands + lchAssert( + Array.isArray(demands) && demands.length > 0, + 'ERR_LCH_QUOTE', + 'Quote has no Payment Demands' + ) + const expiresAt = uint(quote.body.expiresAt, 'Quote expiry', 'ERR_LCH_QUOTE') + const recovery = uint(quote.body.recoveryUntil, 'Quote recovery deadline', 'ERR_LCH_QUOTE') + lchAssert(expiresAt < recovery, 'ERR_LCH_QUOTE', 'Quote recovery window is empty') + let total = 0n + const seen = new Set() + for (const value of demands) { + lchAssert( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array), + 'ERR_LCH_QUOTE', + 'Quote Payment Demand is invalid' + ) + const envelope = value as Record + lchAssert( + envelope.body !== null && + typeof envelope.body === 'object' && + !Array.isArray(envelope.body) && + !(envelope.body instanceof Uint8Array) && + Array.isArray(envelope.signatures) && + envelope.signatures.length > 0 && + envelope.signatures.every(signature => signature instanceof Uint8Array), + 'ERR_LCH_QUOTE', + 'Quote Payment Demand is not a Signed Object' + ) + const demand: SignedObject = { + body: envelope.body as Record, + signatures: envelope.signatures as Uint8Array[] + } + const demandId = await validatePaymentDemand(demand, verifier, options) + const demandIdHex = toHex(demandId) + lchAssert(!seen.has(demandIdHex), 'ERR_LCH_QUOTE', 'Quote repeats a Payment Demand') + seen.add(demandIdHex) + equalId(demand.body.requestId, requestId, 'Demand Request ID') + equalId(demand.body.offerId, offerId, 'Demand Offer ID') + equalId(demand.body.buyer, buyer, 'Demand buyer identity') + lchAssert( + uint(demand.body.expiresAt, 'Demand expiry', 'ERR_LCH_QUOTE') === expiresAt && + uint(demand.body.recoveryUntil, 'Demand recovery deadline', 'ERR_LCH_QUOTE') === recovery, + 'ERR_LCH_QUOTE', + 'Demand deadlines do not match the Quote' + ) + total += uint(demand.body.satoshis, 'Demand amount', 'ERR_LCH_PAYMENT') + lchAssert(total <= 2_100_000_000_000_000n, 'ERR_LCH_PAYMENT', 'Quote total is out of range') + } + lchAssert( + uint(quote.body.totalSatoshis, 'Quote total', 'ERR_LCH_PAYMENT') === total, + 'ERR_LCH_PAYMENT', + 'Quote total does not equal its Payment Demands' + ) + return objectId('quote', quote.body) +} + +export async function validatePaymentDelivery( + delivery: SignedObject, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier() +): Promise { + const buyer = memberBytes(delivery.body, 'buyer', 33, 'Buyer identity') + await verifySignedObject('payment-delivery', delivery, verifier, buyer) + memberBytes(delivery.body, 'demandId', 32, 'Demand ID') + memberBytes(delivery.body, 'requestId', 32, 'Request ID') + memberBytes(delivery.body, 'derivationPrefix', 32, 'Derivation prefix') + memberBytes(delivery.body, 'derivationSuffix', 32, 'Derivation suffix') + memberBytes(delivery.body, 'atomicBeef', undefined, 'Atomic BEEF') + outputIndex(delivery.body.outputIndex) + return objectId('payment-delivery', delivery.body) +} + +export async function validatePaymentReadiness( + readiness: SignedObject, + demand: SignedObject, + now: LCHUint, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier(), + options: AcquisitionValidationOptions = {} +): Promise { + const demandId = await validatePaymentDemand(demand, verifier, options) + const payee = memberBytes(readiness.body, 'payee', 33, 'Payee identity') + await verifySignedObject('payment-readiness', readiness, verifier, payee) + equalId(readiness.body.demandId, demandId, 'Readiness Demand ID') + equalId( + readiness.body.requestId, + memberBytes(demand.body, 'requestId', 32, 'Demand Request ID'), + 'Readiness Request ID' + ) + equalId( + readiness.body.payee, + memberBytes(demand.body, 'payee', 33, 'Demand Payee'), + 'Readiness Payee' + ) + equalId( + readiness.body.buyer, + memberBytes(demand.body, 'buyer', 33, 'Demand buyer identity'), + 'Readiness buyer identity' + ) + const issuedAt = uint(readiness.body.issuedAt, 'Readiness issue time', 'ERR_LCH_PAYMENT') + const readyUntil = uint(readiness.body.readyUntil, 'Readiness deadline', 'ERR_LCH_PAYMENT') + const current = uint(now, 'Readiness validation time', 'ERR_LCH_PAYMENT') + const expiresAt = uint(demand.body.expiresAt, 'Demand expiry', 'ERR_LCH_QUOTE') + const recovery = uint(demand.body.recoveryUntil, 'Demand recovery deadline', 'ERR_LCH_QUOTE') + lchAssert( + issuedAt <= current && current < readyUntil && readyUntil <= expiresAt, + 'ERR_LCH_PAYMENT', + 'Payment Readiness is not currently valid' + ) + lchAssert( + uint(readiness.body.recoveryUntil, 'Readiness recovery deadline', 'ERR_LCH_PAYMENT') === + recovery, + 'ERR_LCH_PAYMENT', + 'Payment Readiness recovery deadline does not match the Demand' + ) + return objectId('payment-readiness', readiness.body) +} + +export async function validatePaymentReceipt( + receipt: SignedObject, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier() +): Promise { + const payee = memberBytes(receipt.body, 'payee', 33, 'Payee identity') + await verifySignedObject('payment-receipt', receipt, verifier, payee) + memberBytes(receipt.body, 'demandId', 32, 'Demand ID') + memberBytes(receipt.body, 'requestId', 32, 'Request ID') + memberBytes(receipt.body, 'txid', 32, 'Transaction ID') + outputIndex(receipt.body.outputIndex) + uint(receipt.body.satoshis, 'Receipt amount', 'ERR_LCH_PAYMENT') + uint(receipt.body.receivedAt, 'Receipt time', 'ERR_LCH_PAYMENT') + return objectId('payment-receipt', receipt.body) +} + +function secureRandom(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) +} + +function bytes( + value: unknown, + length: number | undefined, + name: string +): asserts value is Uint8Array { + lchAssert( + value instanceof Uint8Array && + value.length > 0 && + (length === undefined || value.length === length), + 'ERR_LCH_FRAMING', + `${name} is invalid` + ) +} + +function memberBytes( + body: Record, + key: string, + length: number | undefined, + name: string +): Uint8Array { + const value = body[key] + bytes(value, length, name) + return value +} + +function uint( + value: unknown, + name: string, + code: 'ERR_LCH_LICENSE' | 'ERR_LCH_PAYMENT' | 'ERR_LCH_QUOTE' +): bigint { + lchAssert( + typeof value === 'bigint' || (typeof value === 'number' && Number.isSafeInteger(value)), + code, + `${name} must be an exact integer` + ) + const result = BigInt(value) + lchAssert(result >= 0n && result <= MAX_UINT64, code, `${name} is outside uint64`) + return result +} + +function nonempty(value: unknown, name: string): asserts value is string { + lchAssert(typeof value === 'string' && value.length > 0, 'ERR_LCH_FRAMING', `${name} is absent`) +} + +function endpoint(value: unknown, allowInsecureLocal = false): void { + nonempty(value, 'Endpoint') + let parsed: URL + try { + parsed = new URL(value) + } catch { + lchAssert(false, 'ERR_LCH_ENDPOINT', 'Endpoint is not an absolute URL') + } + lchAssert( + (parsed.protocol === 'https:' || + (allowInsecureLocal && + parsed.protocol === 'http:' && + ['127.0.0.1', '[::1]', 'localhost'].includes(parsed.hostname))) && + parsed.username === '' && + parsed.password === '' && + parsed.hash === '', + 'ERR_LCH_ENDPOINT', + 'Endpoint must be HTTPS without userinfo or fragment' + ) +} + +function isAllowedLocalOrigin(value: unknown, allowed: readonly string[] | undefined): boolean { + if (typeof value !== 'string' || allowed === undefined) return false + try { + return allowed.includes(new URL(value).origin) + } catch { + return false + } +} + +function extensions(values: readonly string[] | undefined): void { + if (values === undefined) return + const unique = new Set(values) + lchAssert( + unique.size === values.length, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Critical identifiers repeat' + ) + for (const value of values) { + let parsed: URL + try { + parsed = new URL(value) + } catch { + lchAssert(false, 'ERR_LCH_PROFILE_UNSUPPORTED', 'Critical identifier is not absolute') + } + lchAssert( + parsed.protocol.length > 1, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Critical identifier is not absolute' + ) + } +} + +function outputIndex(value: unknown): asserts value is number { + lchAssert( + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0, + 'ERR_LCH_PAYMENT', + 'Payment output index is invalid' + ) +} + +function equalId(value: LCHValue | undefined, expected: Uint8Array, name: string): void { + lchAssert( + value instanceof Uint8Array && toHex(value) === toHex(expected), + 'ERR_LCH_QUOTE', + `${name} does not match` + ) +} + +function selection(value: LCHValue | undefined): Selection { + lchAssert( + value !== undefined && + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array), + 'ERR_LCH_SELECTION', + 'Selection is invalid' + ) + return normalizeSelection(value as unknown as Selection) +} diff --git a/packages/content/lch/src/authority.ts b/packages/content/lch/src/authority.ts new file mode 100644 index 000000000..a2e85dda8 --- /dev/null +++ b/packages/content/lch/src/authority.ts @@ -0,0 +1,270 @@ +import { LCH_LIMITS } from './constants.js' +import { lchAssert } from './errors.js' +import { objectId, toHex } from './hash.js' +import { verifySignedObject } from './objects.js' +import type { + LCHSignatureVerifier, + LCHValue, + RevocationObservation, + RevocationSource +} from './types.js' + +export interface AuthorityBody { + version: 1 + assetId: Uint8Array + grantor: Uint8Array + grantee: Uint8Array + interests: string[] + capabilities: string[] + policyActions?: string[] + usageProfiles?: string[] + notBefore: number | bigint + notAfter?: number | bigint + mayDelegate: boolean + remainingDepth?: number | bigint + revocationOutpoint?: string + revocationMaxAgeSeconds?: number | bigint + nonce: Uint8Array +} + +export interface AuthorityRequirement { + controller: Uint8Array + actor: Uint8Array + assetId: Uint8Array + interest: string + capability: string + policyAction?: string + usageProfile?: string + now: bigint + network: RevocationObservation['network'] +} + +function includes(values: readonly string[] | undefined, value: string | undefined): boolean { + return value === undefined || values === undefined || values.includes(value) +} + +function isSubset( + child: readonly string[] | undefined, + parent: readonly string[] | undefined +): boolean { + if (parent === undefined) return true + return child !== undefined && child.every(value => parent.includes(value)) +} + +function validateAuthorityBody(body: AuthorityBody): void { + lchAssert( + body.version === 1 && + body.assetId.length === 32 && + body.grantor.length === 33 && + body.grantee.length === 33 && + body.nonce.length === 16, + 'ERR_LCH_AUTHORITY', + 'Authority body has invalid version or field lengths' + ) + for (const [name, values] of [ + ['interests', body.interests], + ['capabilities', body.capabilities], + ['policyActions', body.policyActions], + ['usageProfiles', body.usageProfiles] + ] as const) { + if (values === undefined) continue + lchAssert( + values.length > 0 && + values.every(value => value.length > 0) && + new Set(values).size === values.length, + 'ERR_LCH_AUTHORITY', + `Authority ${name} must be nonempty and unique` + ) + } + const notBefore = BigInt(body.notBefore) + if (body.notAfter !== undefined) { + lchAssert( + BigInt(body.notAfter) >= notBefore, + 'ERR_LCH_AUTHORITY', + 'Authority validity interval is inverted' + ) + } + if (body.remainingDepth !== undefined) { + const remainingDepth = BigInt(body.remainingDepth) + lchAssert( + remainingDepth >= 0n && remainingDepth <= BigInt(LCH_LIMITS.authorityDepth - 1), + 'ERR_LCH_AUTHORITY', + 'Authority remaining depth is invalid' + ) + } +} + +function rejectDelegationWidening(parent: AuthorityBody, child: AuthorityBody): void { + lchAssert( + isSubset(child.interests, parent.interests) && + isSubset(child.capabilities, parent.capabilities) && + isSubset(child.policyActions, parent.policyActions) && + isSubset(child.usageProfiles, parent.usageProfiles), + 'ERR_LCH_AUTHORITY', + 'Delegated Authority widens a scope' + ) + lchAssert( + BigInt(child.notBefore) >= BigInt(parent.notBefore), + 'ERR_LCH_AUTHORITY', + 'Delegated Authority widens its start time' + ) + if (parent.notAfter !== undefined) { + lchAssert( + child.notAfter !== undefined && BigInt(child.notAfter) <= BigInt(parent.notAfter), + 'ERR_LCH_AUTHORITY', + 'Delegated Authority widens its end time' + ) + } + if (parent.remainingDepth !== undefined && child.mayDelegate) { + const maximum = BigInt(parent.remainingDepth) - 1n + lchAssert( + maximum >= 0n && + child.remainingDepth !== undefined && + BigInt(child.remainingDepth) <= maximum, + 'ERR_LCH_AUTHORITY', + 'Delegated Authority widens its remaining depth' + ) + } +} + +async function verifyRevocation( + body: AuthorityBody, + requirement: AuthorityRequirement, + source: RevocationSource | undefined +): Promise { + const hasOutpoint = body.revocationOutpoint !== undefined + const hasAge = body.revocationMaxAgeSeconds !== undefined + lchAssert( + hasOutpoint === hasAge, + 'ERR_LCH_REVOCATION', + 'Revocation outpoint and maximum age must appear together' + ) + if ( + !hasOutpoint || + body.revocationOutpoint === undefined || + body.revocationMaxAgeSeconds === undefined + ) + return + const ageLimit = BigInt(body.revocationMaxAgeSeconds) + lchAssert( + ageLimit > 0n && ageLimit <= BigInt(LCH_LIMITS.maxRevocationAgeSeconds), + 'ERR_LCH_REVOCATION', + 'Revocation maximum age is invalid' + ) + const outpoint = /^([\da-f]{64})\.(\d+)$/u.exec(body.revocationOutpoint) + lchAssert( + outpoint !== null && BigInt(outpoint[2]) <= 0xffffffffn, + 'ERR_LCH_REVOCATION', + 'Revocation outpoint is invalid' + ) + lchAssert( + !/^0{64}\.0$/u.test(body.revocationOutpoint), + 'ERR_LCH_REVOCATION', + 'Disabled revocation sentinel is prohibited' + ) + lchAssert(source !== undefined, 'ERR_LCH_REVOCATION', 'No revocation-status source is configured') + const observation = await source.status(body.revocationOutpoint) + lchAssert( + observation.network === requirement.network, + 'ERR_LCH_REVOCATION', + 'Revocation observation is for another network' + ) + lchAssert( + observation.reorganizationAffected !== true, + 'ERR_LCH_REVOCATION', + 'Revocation observation was invalidated by reorganization' + ) + lchAssert( + observation.status === 'unspent', + 'ERR_LCH_REVOCATION', + `Authority status is ${observation.status}` + ) + const age = requirement.now - observation.observedAt + lchAssert(age >= 0n && age <= ageLimit, 'ERR_LCH_REVOCATION', 'Revocation observation is stale') +} + +export async function validateAuthorityChain( + chain: ReadonlyArray<{ body: AuthorityBody; signatures: Uint8Array[] }>, + requirement: AuthorityRequirement, + signatureVerifier: LCHSignatureVerifier, + revocationSource?: RevocationSource +): Promise { + lchAssert( + chain.length > 0 && chain.length <= LCH_LIMITS.authorityDepth, + 'ERR_LCH_AUTHORITY', + 'Authority chain length is invalid' + ) + const seen = new Set() + const seenActors = new Set([toHex(requirement.controller)]) + let expectedGrantor = requirement.controller + let parent: AuthorityBody | undefined + for (let index = 0; index < chain.length; index += 1) { + const body = chain[index].body + validateAuthorityBody(body) + if (parent !== undefined) rejectDelegationWidening(parent, body) + await verifySignedObject( + 'authority', + chain[index] as unknown as { body: Record; signatures: Uint8Array[] }, + signatureVerifier, + body.grantor + ) + const authorityId = toHex( + await objectId('authority', body as unknown as Record) + ) + lchAssert(!seen.has(authorityId), 'ERR_LCH_CYCLE', 'Repeated authority grant') + seen.add(authorityId) + lchAssert( + !seenActors.has(toHex(body.grantee)), + 'ERR_LCH_CYCLE', + 'Authority actor cycle detected' + ) + seenActors.add(toHex(body.grantee)) + lchAssert( + toHex(body.grantor) === toHex(expectedGrantor), + 'ERR_LCH_AUTHORITY', + 'Authority chain grantor mismatch' + ) + lchAssert( + toHex(body.assetId) === toHex(requirement.assetId), + 'ERR_LCH_AUTHORITY', + 'Authority Asset ID mismatch' + ) + lchAssert( + body.interests.includes(requirement.interest) && + body.capabilities.includes(requirement.capability), + 'ERR_LCH_AUTHORITY', + 'Authority scope does not cover the requested role' + ) + lchAssert( + includes(body.policyActions, requirement.policyAction) && + includes(body.usageProfiles, requirement.usageProfile), + 'ERR_LCH_AUTHORITY', + 'Authority action or profile is out of scope' + ) + const notBefore = BigInt(body.notBefore) + lchAssert( + requirement.now >= notBefore && + (body.notAfter === undefined || requirement.now <= BigInt(body.notAfter)), + 'ERR_LCH_AUTHORITY', + 'Authority grant is outside its validity interval' + ) + const isFinal = index === chain.length - 1 + if (!isFinal) { + lchAssert(body.mayDelegate, 'ERR_LCH_AUTHORITY', 'Authority grant does not permit delegation') + if (body.remainingDepth !== undefined) + lchAssert( + BigInt(body.remainingDepth) >= BigInt(chain.length - index - 1), + 'ERR_LCH_AUTHORITY', + 'Authority delegation depth exceeded' + ) + } + await verifyRevocation(body, requirement, revocationSource) + expectedGrantor = body.grantee + parent = body + } + lchAssert( + toHex(expectedGrantor) === toHex(requirement.actor), + 'ERR_LCH_AUTHORITY', + 'Authority chain does not end at the required actor' + ) +} diff --git a/packages/content/lch/src/c2pa.ts b/packages/content/lch/src/c2pa.ts new file mode 100644 index 000000000..ce48f5a97 --- /dev/null +++ b/packages/content/lch/src/c2pa.ts @@ -0,0 +1,48 @@ +import { lchAssert } from './errors.js' +import { sha256, toHex } from './hash.js' +import type { C2PAAdapter, C2PAIngredientBinding } from './types.js' +import { validateCompositionRecord, type CompositionRecord } from './composition.js' + +export async function validateC2PAComposition( + asset: Uint8Array, + manifest: Uint8Array | undefined, + record: CompositionRecord, + adapter: C2PAAdapter +): Promise { + validateCompositionRecord(record) + if (manifest !== undefined) { + lchAssert( + toHex(await sha256(manifest)) === toHex(record.c2paManifestDigest), + 'ERR_LCH_PROVENANCE', + 'C2PA Manifest digest does not match the Composition Record' + ) + } + const bindings = await adapter.validate(asset, manifest) + for (const ingredient of record.ingredients) { + const binding = bindings.find( + candidate => + toHex(candidate.sourceAssetId) === toHex(ingredient.sourceAssetId) && + candidate.hashedUri.url === ingredient.c2paIngredient.url && + candidate.hashedUri.alg === ingredient.c2paIngredient.alg && + toHex(candidate.hashedUri.hash) === toHex(ingredient.c2paIngredient.hash) + ) + lchAssert( + binding !== undefined, + 'ERR_LCH_PROVENANCE', + 'Composition ingredient is absent from C2PA' + ) + lchAssert( + binding.relationship === ingredient.relationship, + 'ERR_LCH_PROVENANCE', + 'C2PA relationship does not match composition' + ) + } +} + +export class StaticC2PAAdapter implements C2PAAdapter { + constructor(private readonly bindings: C2PAIngredientBinding[]) {} + + async validate(): Promise { + return this.bindings + } +} diff --git a/packages/content/lch/src/cbor.ts b/packages/content/lch/src/cbor.ts new file mode 100644 index 000000000..16a24028b --- /dev/null +++ b/packages/content/lch/src/cbor.ts @@ -0,0 +1,224 @@ +import { LCH_LIMITS } from './constants.js' +import { LCHError, lchAssert } from './errors.js' +import type { LCHValue } from './types.js' + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder('utf-8', { fatal: true }) +const MAX_UINT64 = 0xffffffffffffffffn + +function concat(parts: readonly Uint8Array[]): Uint8Array { + const length = parts.reduce((total, part) => total + part.length, 0) + const output = new Uint8Array(length) + let offset = 0 + for (const part of parts) { + output.set(part, offset) + offset += part.length + } + return output +} + +function compareBytes(left: Uint8Array, right: Uint8Array): number { + const length = Math.min(left.length, right.length) + for (let index = 0; index < length; index += 1) { + const difference = left[index] - right[index] + if (difference !== 0) return difference + } + return left.length - right.length +} + +function encodeHead(major: number, input: number | bigint): Uint8Array { + const value = typeof input === 'number' ? BigInt(input) : input + lchAssert(value >= 0n && value <= MAX_UINT64, 'ERR_LCH_CBOR', 'CBOR uint exceeds uint64') + if (value < 24n) return Uint8Array.of((major << 5) | Number(value)) + if (value <= 0xffn) return Uint8Array.of((major << 5) | 24, Number(value)) + if (value <= 0xffffn) { + return Uint8Array.of((major << 5) | 25, Number(value >> 8n), Number(value & 0xffn)) + } + if (value <= 0xffffffffn) { + return Uint8Array.of( + (major << 5) | 26, + Number((value >> 24n) & 0xffn), + Number((value >> 16n) & 0xffn), + Number((value >> 8n) & 0xffn), + Number(value & 0xffn) + ) + } + const output = new Uint8Array(9) + output[0] = (major << 5) | 27 + let remaining = value + for (let index = 8; index >= 1; index -= 1) { + output[index] = Number(remaining & 0xffn) + remaining >>= 8n + } + return output +} + +function encode(value: LCHValue, depth: number): Uint8Array { + lchAssert(depth <= LCH_LIMITS.cborDepth, 'ERR_LCH_CBOR', 'CBOR nesting limit exceeded') + if (value === null) return Uint8Array.of(0xf6) + if (value === false) return Uint8Array.of(0xf4) + if (value === true) return Uint8Array.of(0xf5) + if (typeof value === 'number') { + lchAssert( + Number.isSafeInteger(value) && value >= 0, + 'ERR_LCH_CBOR', + 'CBOR numbers must be safe uints' + ) + return encodeHead(0, value) + } + if (typeof value === 'bigint') return encodeHead(0, value) + if (typeof value === 'string') { + lchAssert(value.normalize('NFC') === value, 'ERR_LCH_CBOR', 'CBOR text must be NFC') + const bytes = textEncoder.encode(value) + return concat([encodeHead(3, bytes.length), bytes]) + } + if (value instanceof Uint8Array) return concat([encodeHead(2, value.length), value]) + if (Array.isArray(value)) { + lchAssert(value.length <= LCH_LIMITS.cborEntries, 'ERR_LCH_CBOR', 'CBOR array limit exceeded') + return concat([encodeHead(4, value.length), ...value.map(item => encode(item, depth + 1))]) + } + lchAssert( + Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null, + 'ERR_LCH_CBOR', + 'CBOR maps must be plain objects' + ) + const entries = Object.entries(value) + lchAssert(entries.length <= LCH_LIMITS.cborEntries, 'ERR_LCH_CBOR', 'CBOR map limit exceeded') + const encoded = entries.map(([key, item]) => { + lchAssert(item !== undefined, 'ERR_LCH_CBOR', `Undefined CBOR map value: ${key}`) + return [encode(key, depth + 1), encode(item, depth + 1)] as const + }) + encoded.sort((left, right) => compareBytes(left[0], right[0])) + return concat([encodeHead(5, encoded.length), ...encoded.flat()]) +} + +export function encodeDeterministicCbor(value: LCHValue): Uint8Array { + return encode(value, 0) +} + +class Decoder { + private offset = 0 + private entries = 0 + + constructor(private readonly bytes: Uint8Array) {} + + decode(depth = 0): LCHValue { + lchAssert(depth <= LCH_LIMITS.cborDepth, 'ERR_LCH_CBOR', 'CBOR nesting limit exceeded') + lchAssert(this.offset < this.bytes.length, 'ERR_LCH_CBOR', 'Truncated CBOR') + this.entries += 1 + lchAssert(this.entries <= LCH_LIMITS.cborEntries, 'ERR_LCH_CBOR', 'CBOR item limit exceeded') + const head = this.bytes[this.offset] + this.offset += 1 + const major = head >> 5 + const additional = head & 31 + if (major === 7) return this.decodeSimple(additional) + const length = this.readLength(additional) + if (major === 0) return length <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(length) : length + lchAssert( + length <= BigInt(Number.MAX_SAFE_INTEGER), + 'ERR_LCH_CBOR', + 'CBOR allocation exceeds platform limit' + ) + const count = Number(length) + if (major === 2) return this.read(count) + if (major === 3) return this.decodeText(count) + if (major === 4) return this.decodeArray(count, depth) + if (major === 5) return this.decodeMap(count, depth) + throw new LCHError('ERR_LCH_CBOR', `Unsupported CBOR major type ${major}`) + } + + private decodeSimple(additional: number): LCHValue { + if (additional === 20) return false + if (additional === 21) return true + if (additional === 22) return null + throw new LCHError('ERR_LCH_CBOR', 'Unsupported CBOR simple or floating-point value') + } + + private decodeText(count: number): string { + let text: string + try { + text = textDecoder.decode(this.read(count)) + } catch (error) { + throw new LCHError('ERR_LCH_CBOR', 'CBOR text is not valid UTF-8', { cause: error }) + } + lchAssert(text.normalize('NFC') === text, 'ERR_LCH_CBOR', 'CBOR text must be NFC') + return text + } + + private decodeArray(count: number, depth: number): LCHValue[] { + const result: LCHValue[] = [] + for (let index = 0; index < count; index += 1) result.push(this.decode(depth + 1)) + return result + } + + private decodeMap(count: number, depth: number): Record { + const result: Record = Object.create(null) as Record + let previousKey: Uint8Array | undefined + for (let index = 0; index < count; index += 1) { + const start = this.offset + const key = this.decode(depth + 1) + const encodedKey = this.bytes.slice(start, this.offset) + lchAssert(typeof key === 'string', 'ERR_LCH_CBOR', 'LCH CBOR map keys must be text') + if (previousKey !== undefined) { + lchAssert( + compareBytes(previousKey, encodedKey) < 0, + 'ERR_LCH_CBOR', + 'CBOR map keys are duplicated or unordered' + ) + } + previousKey = encodedKey + result[key] = this.decode(depth + 1) + } + return result + } + + done(): boolean { + return this.offset === this.bytes.length + } + + private readLength(additional: number): bigint { + if (additional < 24) return BigInt(additional) + if (additional === 24) { + const value = BigInt(this.read(1)[0]) + lchAssert(value >= 24n, 'ERR_LCH_CBOR', 'Non-shortest CBOR length') + return value + } + if (additional === 25) { + const bytes = this.read(2) + const value = BigInt((bytes[0] << 8) | bytes[1]) + lchAssert(value > 0xffn, 'ERR_LCH_CBOR', 'Non-shortest CBOR length') + return value + } + if (additional === 26) { + const bytes = this.read(4) + let value = 0n + for (const byte of bytes) value = (value << 8n) | BigInt(byte) + lchAssert(value > 0xffffn, 'ERR_LCH_CBOR', 'Non-shortest CBOR length') + return value + } + if (additional === 27) { + const bytes = this.read(8) + let value = 0n + for (const byte of bytes) value = (value << 8n) | BigInt(byte) + lchAssert(value > 0xffffffffn, 'ERR_LCH_CBOR', 'Non-shortest CBOR length') + return value + } + throw new LCHError('ERR_LCH_CBOR', 'Indefinite-length or reserved CBOR item') + } + + private read(length: number): Uint8Array { + lchAssert(this.offset + length <= this.bytes.length, 'ERR_LCH_CBOR', 'Truncated CBOR') + const result = this.bytes.slice(this.offset, this.offset + length) + this.offset += length + return result + } +} + +export function decodeDeterministicCbor(bytes: Uint8Array): LCHValue { + const decoder = new Decoder(bytes) + const value = decoder.decode() + lchAssert(decoder.done(), 'ERR_LCH_CBOR', 'Trailing bytes after CBOR value') + const encoded = encodeDeterministicCbor(value) + lchAssert(compareBytes(encoded, bytes) === 0, 'ERR_LCH_CBOR', 'CBOR is not deterministic') + return value +} diff --git a/packages/content/lch/src/cli.ts b/packages/content/lch/src/cli.ts new file mode 100644 index 000000000..516ce5db1 --- /dev/null +++ b/packages/content/lch/src/cli.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { decodeDeterministicCbor, encodeDeterministicCbor } from './cbor.js' +import { parseLCH } from './framing.js' +import { objectIri, toHex } from './hash.js' +import type { LCHValue } from './types.js' + +export interface LCHCLIRuntime { + args: string[] + read(path: string): Promise + write(message: string): void +} + +function usage(): string { + return 'Usage: lch [file]\n\nCommands:\n inspect Decode an .lch header\n verify Verify framing and canonical CBOR\n id Compute an object IRI from CBOR\n --help Show this help\n' +} + +function diagnostic(value: LCHValue): unknown { + if (value instanceof Uint8Array) { + return { $bytes: Buffer.from(value).toString('base64url') } + } + if (typeof value === 'bigint') return { $uint: value.toString() } + if (Array.isArray(value)) return value.map(diagnostic) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, diagnostic(item)])) + } + return value +} + +export async function runLCHCLI(runtime: LCHCLIRuntime): Promise { + const [command, first, second] = runtime.args + if (command === undefined || command === '--help' || command === '-h') { + runtime.write(usage()) + return + } + if (command === 'inspect' || command === 'verify') { + if (first === undefined) throw new Error('A file path is required') + const parsed = parseLCH(await runtime.read(first)) + if (command === 'inspect') { + runtime.write(JSON.stringify(diagnostic(parsed.header), null, 2) + '\n') + } else { + runtime.write( + 'valid header=' + + parsed.headerBytes.length + + ' ciphertext=' + + (parsed.ciphertext?.length ?? 0) + + '\n' + ) + } + return + } + if (command === 'id') { + if (first === undefined || second === undefined) { + throw new Error('Object type and CBOR file are required') + } + const bytes = await runtime.read(second) + const body = decodeDeterministicCbor(bytes) + if (toHex(encodeDeterministicCbor(body)) !== toHex(bytes)) { + throw new Error('CBOR is not canonical') + } + runtime.write((await objectIri(first as never, body)) + '\n') + return + } + throw new Error('Unknown command: ' + command) +} + +const invokedPath = process.argv[1] +if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) { + try { + await runLCHCLI({ + args: process.argv.slice(2), + read: async path => new Uint8Array(await readFile(path)), + write: message => process.stdout.write(message) + }) + } catch (error) { + process.stderr.write((error instanceof Error ? error.message : String(error)) + '\n') + process.exitCode = 1 + } +} diff --git a/packages/content/lch/src/composition.ts b/packages/content/lch/src/composition.ts new file mode 100644 index 000000000..9cdbac899 --- /dev/null +++ b/packages/content/lch/src/composition.ts @@ -0,0 +1,149 @@ +import { LCH_LIMITS, LCH_MECHANISMS } from './constants.js' +import { lchAssert } from './errors.js' +import { selectionsIntersect, validateNormalizedSelection } from './selection.js' +import { toHex } from './hash.js' +import type { LCHValue, Selection } from './types.js' + +export interface CompositionIngredient { + sourceAssetId: Uint8Array + sourceLicenseId: Uint8Array + c2paIngredient: { url: string; alg?: string; hash: Uint8Array } + relationship: 'componentOf' | 'inputTo' + sourceSelection: Selection + derivedSelection: Selection + mappingProfile: string + nextPolicy?: Record + settlementReceiptIds?: Uint8Array[] + metadata?: Record +} + +export interface CompositionRecord { + version: 1 + c2paManifestDigest: Uint8Array + ingredients: CompositionIngredient[] + critical?: string[] +} + +export function validateCompositionRecord(record: CompositionRecord): void { + lchAssert( + record.version === 1 && + record.c2paManifestDigest.length === 32 && + record.ingredients.length > 0 && + record.ingredients.length <= LCH_LIMITS.cborEntries, + 'ERR_LCH_PROVENANCE', + 'Composition record is invalid' + ) + record.ingredients.forEach(validateIngredient) + const bindings = record.ingredients.map(ingredient => { + const { url, alg, hash } = ingredient.c2paIngredient + return `${url}\u0000${alg ?? ''}\u0000${toHex(hash)}` + }) + lchAssert( + new Set(bindings).size === bindings.length, + 'ERR_LCH_PROVENANCE', + 'Composition ingredients must bind distinct C2PA assertions' + ) +} + +export function validateIngredient(ingredient: CompositionIngredient): void { + lchAssert( + ingredient.sourceAssetId.length === 32 && ingredient.sourceLicenseId.length === 32, + 'ERR_LCH_PROVENANCE', + 'Composition IDs must be 32 bytes' + ) + lchAssert( + ingredient.c2paIngredient.url.length > 0 && + ingredient.c2paIngredient.hash.length > 0 && + (ingredient.c2paIngredient.alg === undefined || ingredient.c2paIngredient.alg.length > 0), + 'ERR_LCH_PROVENANCE', + 'Composition C2PA hashed URI is invalid' + ) + lchAssert( + ingredient.mappingProfile === LCH_MECHANISMS.wholePlacement, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Unknown composition mapping profile' + ) + lchAssert( + ingredient.derivedSelection.type === 'all', + 'ERR_LCH_PROVENANCE', + 'Whole placement requires an all derived selection' + ) + validateNormalizedSelection(ingredient.sourceSelection) +} + +export function activeIngredients( + record: CompositionRecord, + derivedSelection: Selection +): CompositionIngredient[] { + validateCompositionRecord(record) + return record.ingredients.filter(ingredient => { + return selectionsIntersect(ingredient.derivedSelection, derivedSelection) + }) +} + +export interface CompositionNode { + assetId: Uint8Array + selection: Selection + record?: CompositionRecord +} + +export async function walkComposition( + root: CompositionNode, + load: (assetId: Uint8Array, selection: Selection) => Promise, + maximumDepth = LCH_LIMITS.compositionDepth +): Promise { + const active = new Set() + const result: CompositionIngredient[] = [] + + async function visit(node: CompositionNode, depth: number): Promise { + lchAssert(depth <= maximumDepth, 'ERR_LCH_CYCLE', 'Composition depth limit exceeded') + const key = toHex(node.assetId) + lchAssert(!active.has(key), 'ERR_LCH_CYCLE', 'Composition cycle detected') + active.add(key) + if (node.record !== undefined) { + for (const ingredient of activeIngredients(node.record, node.selection)) { + result.push(ingredient) + const source = await load(ingredient.sourceAssetId, ingredient.sourceSelection) + if (source !== undefined) await visit(source, depth + 1) + } + } + active.delete(key) + } + + await visit(root, 0) + return result +} + +export class LCHComposer { + private readonly ingredients: CompositionIngredient[] = [] + + constructor(private readonly c2paManifestDigest: Uint8Array) {} + + addWholePlacement( + ingredient: Omit + ): this { + const complete: CompositionIngredient = { + ...ingredient, + derivedSelection: { type: 'all' }, + mappingProfile: LCH_MECHANISMS.wholePlacement + } + validateIngredient(complete) + this.ingredients.push(complete) + return this + } + + build(): CompositionRecord { + lchAssert( + this.c2paManifestDigest.length === 32 && this.ingredients.length > 0, + 'ERR_LCH_PROVENANCE', + 'Composition record is incomplete' + ) + const record: CompositionRecord = { + version: 1, + c2paManifestDigest: this.c2paManifestDigest.slice(), + ingredients: [...this.ingredients] + } + validateCompositionRecord(record) + return record + } +} diff --git a/packages/content/lch/src/constants.ts b/packages/content/lch/src/constants.ts new file mode 100644 index 000000000..146c7e665 --- /dev/null +++ b/packages/content/lch/src/constants.ts @@ -0,0 +1,66 @@ +export const LCH_VERSION = 1 as const +export const LCH_MAGIC = Uint8Array.of(0x4c, 0x43, 0x48, LCH_VERSION) +export const LCH_IRI = 'https://bsv.brc.dev/apps/0170' + +export const LCH_PROFILES = { + fixedRender: `${LCH_IRI}#fixed-render-v1`, + meteredRange: `${LCH_IRI}#metered-range-v1`, + meteredEvent: `${LCH_IRI}#metered-event-v1`, + rental: `${LCH_IRI}#rental-v1`, + composition: `${LCH_IRI}#compose-v1`, + training: `${LCH_IRI}#training-v1` +} as const + +export const LCH_MECHANISMS = { + encryption: `${LCH_IRI}#a256gcm-segmented-v1`, + brc105Single: `${LCH_IRI}#brc105-single-v1`, + brc105Multipay: `${LCH_IRI}#brc105-multipay-v1`, + brc121Single: `${LCH_IRI}#brc121-single-v1`, + brc78Key: `${LCH_IRI}#brc78-key-v1`, + rawKey: `${LCH_IRI}#raw-key-v1`, + wholePlacement: `${LCH_IRI}#whole-placement-v1` +} as const + +export const LCH_SETTLEMENT_PROFILES = { + receiptComplete: `${LCH_IRI}#receipt-complete-v1`, + authorizedOutput: `${LCH_IRI}#authorized-output-v1` +} as const + +export const LCH_TRANSACTION_EVIDENCE_POLICIES = { + signedProcessorAcceptance: `${LCH_IRI}#signed-processor-acceptance-v1` +} as const + +export const LCH_LIMITS = { + headerBytes: 16 * 1024 * 1024, + cborDepth: 64, + cborEntries: 100_000, + authorityDepth: 16, + compositionDepth: 32, + encryptionSegments: 1_000_000, + redirects: 5, + maxRevocationAgeSeconds: 86_400, + minRecoveryPeriodSeconds: 86_400 +} as const + +export const LCH_SIGNING_PROTOCOL = [2, 'message signing'] as const +export const LCH_AAD_PREFIX = new TextEncoder().encode('LCH A256GCM segmented v1\0') +export const LCH_KEY_ID_PREFIX = new TextEncoder().encode('LCH key id v1\0') +export const LCH_OBJECT_TYPES = [ + 'asset', + 'header', + 'authority', + 'offer', + 'selection', + 'license-request', + 'quote', + 'payment-demand', + 'payment-readiness', + 'payment-authorization', + 'payment-delivery', + 'payment-delivery-retrieval', + 'transaction-evidence', + 'payment-delivery-ack', + 'payment-receipt', + 'license', + 'composition-record' +] as const diff --git a/packages/content/lch/src/core.ts b/packages/content/lch/src/core.ts new file mode 100644 index 000000000..a80694a41 --- /dev/null +++ b/packages/content/lch/src/core.ts @@ -0,0 +1,542 @@ +import { LCH_VERSION } from './constants.js' +import { + decryptSegmented, + encryptSegmented, + validateKeyGrantsForSelection, + type SegmentedEncryptionOptions +} from './encryption.js' +import { lchAssert } from './errors.js' +import { frameLCH, parseLCH, type ParsedLCH } from './framing.js' +import { objectId, objectIri, objectPreimage, sha256, toHex } from './hash.js' +import { signObject, verifySignedObject } from './objects.js' +import { fixedTotal, recoveryUntil } from './payment.js' +import { normalizeSelection } from './selection.js' +import { brc77SignerIdentity, PublicBRC77Verifier } from './signatures.js' +import { validateTimeWindow } from './time.js' +import type { + ContentSink, + ContentSource, + KeyGrant, + LCHSignatureVerifier, + LCHSigner, + LCHValue, + LicenseStore, + Selection, + SegmentedEncryptionDescriptor, + SignedObject +} from './types.js' + +const ALL_SELECTION: Selection = { type: 'all' } + +export interface RightsInterest extends Record { + interest: string + holder: { name: string; identifier?: string } + controller: Uint8Array +} + +export interface ProtectedAsset { + asset: Record + assetId: Uint8Array + ciphertext: Uint8Array + keys: Map +} + +export interface ProtectOptions extends SegmentedEncryptionOptions { + mediaType: string + name: string + rights: RightsInterest[] + sink?: ContentSink + workId?: string + metadata?: Record + embedCiphertext?: boolean +} + +export interface PublishedLCH extends ProtectedAsset { + header: Record + bytes: Uint8Array +} + +export class LCHPublisher { + constructor(private readonly signer: LCHSigner) {} + + async protect(plaintext: Uint8Array, options: ProtectOptions): Promise { + const unsafeName = + options.name.includes('/') || + options.name.includes('\\') || + options.name.includes(String.fromCodePoint(0)) + lchAssert( + options.name.length > 0 && !unsafeName && options.name !== '.' && options.name !== '..', + 'ERR_LCH_FRAMING', + 'Unsafe asset name' + ) + lchAssert( + options.rights.length > 0, + 'ERR_LCH_AUTHORITY', + 'Asset must declare at least one rights interest' + ) + const encrypted = await encryptSegmented(plaintext, options) + const locators = options.sink === undefined ? [] : await options.sink.put(encrypted.ciphertext) + const representation: Record = { + ciphertextDigest: await sha256(encrypted.ciphertext), + ciphertextLength: encrypted.ciphertext.length, + plaintextDigest: await sha256(plaintext), + encryption: encrypted.descriptor as unknown as Record, + locators + } + const asset: Record = { + mediaType: options.mediaType, + name: options.name, + ...(options.workId === undefined ? {} : { workId: options.workId }), + representation, + rights: options.rights, + ...(options.metadata === undefined ? {} : { metadata: options.metadata }) + } + return { + asset, + assetId: await objectId('asset', asset), + ciphertext: encrypted.ciphertext, + keys: encrypted.keys + } + } + + async publish( + protectedAsset: ProtectedAsset, + acquisition: Array>, + embedCiphertext = true + ): Promise { + lchAssert( + acquisition.length > 0, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Header requires an acquisition entry' + ) + const body: Record = { + lch: LCH_VERSION, + asset: protectedAsset.asset, + acquisition + } + const signatures = [await this.signer.sign(objectPreimage('header', body))] + const header = { ...body, signatures } + return { + ...protectedAsset, + header, + bytes: frameLCH(header, embedCiphertext ? protectedAsset.ciphertext : undefined) + } + } +} + +export interface InspectedLCH extends ParsedLCH { + asset: Record + assetId: Uint8Array + representation: Record + headerSigners: Uint8Array[] +} + +export interface LCHReaderOptions { + verifier?: LCHSignatureVerifier + authorizeHeaderSigner?: (signer: Uint8Array, assetId: Uint8Array) => Promise +} + +export class LCHReader { + constructor( + private readonly source: ContentSource, + private readonly licenseStore?: LicenseStore, + private readonly options: LCHReaderOptions = {} + ) {} + + async inspect(bytes: Uint8Array): Promise { + const parsed = parseLCH(bytes) + lchAssert(parsed.header.lch === LCH_VERSION, 'ERR_LCH_FRAMING', 'Unsupported LCH version') + lchAssert( + Array.isArray(parsed.header.acquisition) && parsed.header.acquisition.length > 0, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Header has no acquisition entry' + ) + const asset = mapValue(parsed.header.asset, 'Asset Body') + validateAssetShape(asset) + const representation = mapValue(asset.representation, 'representation') + const assetId = await objectId('asset', asset) + const headerSigners = await verifyHeaderAuthorization( + parsed.header, + asset, + assetId, + this.options.verifier ?? new PublicBRC77Verifier(), + this.options.authorizeHeaderSigner + ) + if (parsed.ciphertext !== undefined) await validateCiphertext(parsed.ciphertext, representation) + else { + const locators = representation.locators + lchAssert( + Array.isArray(locators) && locators.length > 0, + 'ERR_LCH_CONTENT_UNAVAILABLE', + 'Detached LCH has no content locator' + ) + } + return { ...parsed, asset, assetId, representation, headerSigners } + } + + async resolve(inspected: InspectedLCH): Promise { + if (inspected.ciphertext !== undefined) return inspected.ciphertext + const locators = inspected.representation.locators + lchAssert( + Array.isArray(locators), + 'ERR_LCH_CONTENT_UNAVAILABLE', + 'Representation locators are invalid' + ) + let lastError: unknown + for (const locator of locators) { + if (typeof locator !== 'string') continue + try { + const ciphertext = await this.source.read(locator) + await validateCiphertext(ciphertext, inspected.representation) + return ciphertext + } catch (error) { + lastError = error + } + } + throw new Error('No valid ciphertext source was available', { cause: lastError }) + } + + async decrypt( + inspected: InspectedLCH, + keys: ReadonlyMap, + selection: Selection = ALL_SELECTION + ): Promise { + const ciphertext = await this.resolve(inspected) + const descriptor = mapValue(inspected.representation.encryption, 'encryption descriptor') + const plaintext = await decryptSegmented( + ciphertext, + descriptor as unknown as SegmentedEncryptionDescriptor, + keys, + selection + ) + if (selection.type === 'all' && inspected.representation.plaintextDigest !== undefined) { + const digest = inspected.representation.plaintextDigest + lchAssert( + digest instanceof Uint8Array && + digest.length === 32 && + toHex(await sha256(plaintext)) === toHex(digest), + 'ERR_LCH_CONTENT_DIGEST', + 'Plaintext digest mismatch' + ) + } + return plaintext + } + + async storedLicense( + assetId: Uint8Array, + offerId?: Uint8Array + ): Promise { + const stored = await this.licenseStore?.get( + toHex(assetId), + offerId === undefined ? undefined : toHex(offerId) + ) + return stored?.license + } +} + +export interface OfferOptions { + assetId: Uint8Array + usageProfile: string + seller: Uint8Array + licenseIssuer: Uint8Array + requiredInterests: string[] + policy: Record + payment: Record + keyDelivery: Record + enforcement: Record + notBefore: number | bigint + notAfter?: number | bigint + nonce: Uint8Array + authorityIds?: Uint8Array[] +} + +export interface LicenseOptions { + assetId: Uint8Array + offerId: Uint8Array + requestId: Uint8Array + issuer: Uint8Array + subject: Uint8Array + issuedAt: number | bigint + agreement: Record + selection: Selection + segmentSelection?: Extract + fulfillments?: Array> + keyGrants?: KeyGrant[] + encryption?: SegmentedEncryptionDescriptor + notBefore?: number | bigint + notAfter?: number | bigint +} + +export class LCHIssuer { + constructor(private readonly signer: LCHSigner) {} + + async createOffer(options: OfferOptions): Promise { + lchAssert( + toHex(options.seller) === toHex(this.signer.identityKey), + 'ERR_LCH_SIGNATURE', + 'Offer signer is not the Seller' + ) + const recovery = options.payment.recoveryPeriodSeconds + lchAssert( + typeof recovery === 'number' || typeof recovery === 'bigint', + 'ERR_LCH_QUOTE', + 'Payment offer must declare a recovery period' + ) + recoveryUntil(0n, recovery) + validateTimeWindow({ notBefore: options.notBefore, notAfter: options.notAfter }) + lchAssert( + typeof options.keyDelivery.mechanism === 'string' && + typeof options.enforcement.class === 'string', + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Offer mechanisms are incomplete' + ) + const body: Record = { + version: 1, + assetId: options.assetId, + usageProfile: options.usageProfile, + seller: options.seller, + licenseIssuer: options.licenseIssuer, + requiredInterests: options.requiredInterests, + ...(options.authorityIds === undefined ? {} : { authorityIds: options.authorityIds }), + policy: options.policy, + payment: options.payment, + keyDelivery: options.keyDelivery, + enforcement: options.enforcement, + notBefore: options.notBefore, + ...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }), + nonce: options.nonce + } + return signObject('offer', body, this.signer) + } + + async issueLicense(options: LicenseOptions): Promise { + lchAssert( + toHex(options.issuer) === toHex(this.signer.identityKey), + 'ERR_LCH_SIGNATURE', + 'License signer is not the issuer' + ) + const selection = normalizeSelection(options.selection) + validateTimeWindow({ notBefore: options.notBefore, notAfter: options.notAfter }) + const segmentSelection = + options.segmentSelection === undefined + ? undefined + : (normalizeSelection(options.segmentSelection) as Extract) + if (options.encryption !== undefined) { + const keySelection = segmentSelection ?? (selection.type === 'all' ? selection : undefined) + lchAssert( + keySelection !== undefined, + 'ERR_LCH_SELECTION', + 'A partial encrypted License requires exact segment selection' + ) + validateKeyGrantsForSelection(options.encryption, keySelection, options.keyGrants ?? []) + } + const body: Record = { + version: 1, + assetId: options.assetId, + offerId: options.offerId, + requestId: options.requestId, + issuer: options.issuer, + subject: options.subject, + issuedAt: options.issuedAt, + ...(options.notBefore === undefined ? {} : { notBefore: options.notBefore }), + ...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }), + agreement: options.agreement, + selection: selection as unknown as Record, + ...(segmentSelection === undefined + ? {} + : { + segmentSelection: segmentSelection as unknown as Record + }), + fulfillments: options.fulfillments ?? [], + keyGrants: (options.keyGrants ?? []) as unknown as Array> + } + return signObject('license', body, this.signer) + } + + quoteFixed(requirements: ReadonlyArray<{ satoshis: number | bigint }>): bigint { + return fixedTotal(requirements) + } +} + +export interface AcquisitionTransport { + preflight(request: SignedObject): Promise + quote(request: SignedObject): Promise + deliver(quote: SignedObject, payment: Uint8Array): Promise + recover(requestId: Uint8Array): Promise +} + +export class LCHAcquisition { + constructor(private readonly transport: AcquisitionTransport) {} + + preflight(request: SignedObject): Promise { + return this.transport.preflight(request) + } + + quote(request: SignedObject): Promise { + return this.transport.quote(request) + } + + deliver(quote: SignedObject, finalizedAtomicBeef: Uint8Array): Promise { + return this.transport.deliver(quote, finalizedAtomicBeef) + } + + recover(requestId: Uint8Array): Promise { + return this.transport.recover(requestId) + } +} + +async function validateCiphertext( + ciphertext: Uint8Array, + representation: Record +): Promise { + const length = representation.ciphertextLength + const digest = representation.ciphertextDigest + lchAssert( + (typeof length === 'number' || typeof length === 'bigint') && + BigInt(ciphertext.length) === BigInt(length), + 'ERR_LCH_CONTENT_DIGEST', + 'Ciphertext length mismatch' + ) + lchAssert( + digest instanceof Uint8Array && + digest.length === 32 && + toHex(await sha256(ciphertext)) === toHex(digest), + 'ERR_LCH_CONTENT_DIGEST', + 'Ciphertext digest mismatch' + ) +} + +function validateAssetShape(asset: Record): void { + lchAssert( + typeof asset.mediaType === 'string' && + asset.mediaType.length > 0 && + typeof asset.name === 'string' && + asset.name.length > 0, + 'ERR_LCH_FRAMING', + 'Asset media type or name is invalid' + ) + const unsafeName = + asset.name.includes('/') || + asset.name.includes('\\') || + asset.name.includes(String.fromCodePoint(0)) + lchAssert( + !unsafeName && asset.name !== '.' && asset.name !== '..', + 'ERR_LCH_FRAMING', + 'Asset name is unsafe' + ) + const representation = mapValue(asset.representation, 'representation') + const ciphertextLength = representation.ciphertextLength + const validCiphertextLength = + typeof ciphertextLength === 'bigint' + ? ciphertextLength >= 0n + : typeof ciphertextLength === 'number' && + Number.isSafeInteger(ciphertextLength) && + ciphertextLength >= 0 + lchAssert( + representation.ciphertextDigest instanceof Uint8Array && + representation.ciphertextDigest.length === 32 && + validCiphertextLength && + Array.isArray(representation.locators) && + representation.locators.length <= 64 && + representation.locators.every(locator => typeof locator === 'string'), + 'ERR_LCH_FRAMING', + 'Asset representation is invalid' + ) +} + +async function verifyHeaderAuthorization( + header: Record, + asset: Record, + assetId: Uint8Array, + verifier: LCHSignatureVerifier, + authorize: LCHReaderOptions['authorizeHeaderSigner'] +): Promise { + const signatures = header.signatures + lchAssert( + Array.isArray(signatures) && + signatures.length > 0 && + signatures.every(signature => signature instanceof Uint8Array), + 'ERR_LCH_SIGNATURE', + 'Header signatures are invalid' + ) + const rights = asset.rights + lchAssert( + Array.isArray(rights) && rights.length > 0, + 'ERR_LCH_AUTHORITY', + 'Asset rights are absent' + ) + const controllers = new Set( + rights.map(right => { + const map = mapValue(right, 'rights interest') + const holder = mapValue(map.holder, 'rights holder') + const controller = map.controller + lchAssert( + typeof map.interest === 'string' && + map.interest.length > 0 && + typeof holder.name === 'string' && + holder.name.length > 0 && + controller instanceof Uint8Array && + controller.length === 33, + 'ERR_LCH_AUTHORITY', + 'Rights interest or Controller is invalid' + ) + return toHex(controller) + }) + ) + const body = { ...header } + delete body.signatures + const preimage = objectPreimage('header', body) + const accepted: Uint8Array[] = [] + for (const signature of signatures as Uint8Array[]) { + let signer: Uint8Array + try { + signer = brc77SignerIdentity(signature) + } catch { + continue + } + if (!(await verifier.verify(preimage, signature))) continue + const authorized = + controllers.has(toHex(signer)) || + (authorize !== undefined && (await authorize(signer, assetId))) + if (authorized) accepted.push(signer) + } + lchAssert(accepted.length > 0, 'ERR_LCH_AUTHORITY', 'No valid Header signer is authorized') + return accepted +} + +function mapValue(value: LCHValue | undefined, name: string): Record { + lchAssert( + value !== undefined && + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array), + 'ERR_LCH_FRAMING', + `${name} must be a map` + ) + return value +} + +export async function validateOffer( + offer: SignedObject, + verifier: LCHSignatureVerifier, + seller: Uint8Array +): Promise { + await verifySignedObject('offer', offer, verifier, seller) + const notBefore = offer.body.notBefore + const notAfter = offer.body.notAfter + lchAssert( + typeof notBefore === 'number' || typeof notBefore === 'bigint', + 'ERR_LCH_LICENSE', + 'Offer notBefore is absent' + ) + lchAssert( + notAfter === undefined || typeof notAfter === 'number' || typeof notAfter === 'bigint', + 'ERR_LCH_LICENSE', + 'Offer notAfter is invalid' + ) + validateTimeWindow({ notBefore, notAfter }) + return objectIri('offer', offer.body) +} + +export { LCH_MECHANISMS } from './constants.js' diff --git a/packages/content/lch/src/encryption.ts b/packages/content/lch/src/encryption.ts new file mode 100644 index 000000000..9ab115613 --- /dev/null +++ b/packages/content/lch/src/encryption.ts @@ -0,0 +1,315 @@ +import { LCH_AAD_PREFIX, LCH_KEY_ID_PREFIX, LCH_LIMITS, LCH_MECHANISMS } from './constants.js' +import { LCHError, lchAssert } from './errors.js' +import { concatBytes, sha256, toHex, uint64be } from './hash.js' +import type { + EncryptionResult, + KeyPeriod, + SegmentedEncryptionDescriptor, + Selection +} from './types.js' + +const ALL_SELECTION: Selection = { type: 'all' } + +export interface SegmentedEncryptionOptions { + segmentSize?: number + keyPeriodSegments?: number + random?: (length: number) => Uint8Array +} + +function secureRandom(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) +} + +function ownedBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.slice().buffer +} + +function toSafeNumber(value: number | bigint, name: string): number { + const result = typeof value === 'bigint' ? Number(value) : value + lchAssert( + Number.isSafeInteger(result) && result >= 0, + 'ERR_LCH_KEY', + `${name} is not a safe uint` + ) + return result +} + +export async function keyIdFor(cek: Uint8Array): Promise { + lchAssert(cek.length === 32, 'ERR_LCH_KEY', 'CEK must contain 32 bytes') + return sha256(concatBytes(LCH_KEY_ID_PREFIX, cek)) +} + +function periodFor(index: number, periods: readonly KeyPeriod[]): KeyPeriod { + const period = periods.find(candidate => { + const first = toSafeNumber(candidate.firstSegment, 'firstSegment') + const count = toSafeNumber(candidate.segmentCount, 'segmentCount') + return index >= first && index < first + count + }) + if (period === undefined) + throw new LCHError('ERR_LCH_KEY', `No key period covers segment ${index}`) + return period +} + +function segmentAad( + descriptor: SegmentedEncryptionDescriptor, + index: number, + keyId: Uint8Array +): Uint8Array { + return concatBytes( + LCH_AAD_PREFIX, + descriptor.encryptionId, + uint64be(index), + uint64be(descriptor.segmentCount), + uint64be(descriptor.plaintextLength), + keyId + ) +} + +function segmentIv(prefix: Uint8Array, index: number): Uint8Array { + lchAssert(prefix.length === 4, 'ERR_LCH_KEY', 'Nonce prefix must contain four bytes') + return concatBytes(prefix, uint64be(index)) +} + +export function validateEncryptionDescriptor(descriptor: SegmentedEncryptionDescriptor): void { + lchAssert( + descriptor.algorithm === LCH_MECHANISMS.encryption, + 'ERR_LCH_KEY', + 'Unsupported encryption mechanism' + ) + lchAssert( + descriptor.encryptionId.length === 32, + 'ERR_LCH_KEY', + 'Encryption ID must contain 32 bytes' + ) + lchAssert( + descriptor.noncePrefix.length === 4, + 'ERR_LCH_KEY', + 'Nonce prefix must contain four bytes' + ) + const plaintextLength = toSafeNumber(descriptor.plaintextLength, 'plaintextLength') + const segmentSize = toSafeNumber(descriptor.segmentSize, 'segmentSize') + const segmentCount = toSafeNumber(descriptor.segmentCount, 'segmentCount') + lchAssert( + segmentSize > 0 && + segmentCount > 0 && + segmentCount <= LCH_LIMITS.encryptionSegments && + descriptor.keyPeriods.length <= LCH_LIMITS.cborEntries, + 'ERR_LCH_KEY', + 'Segment size, count, or key-period count is invalid' + ) + const expectedCount = Math.max(1, Math.ceil(plaintextLength / segmentSize)) + lchAssert( + segmentCount === expectedCount, + 'ERR_LCH_KEY', + 'Segment count does not match plaintext length' + ) + let cursor = 0 + for (const period of descriptor.keyPeriods) { + const first = toSafeNumber(period.firstSegment, 'firstSegment') + const count = toSafeNumber(period.segmentCount, 'key period segmentCount') + lchAssert( + period.keyId.length === 32 && count > 0 && first === cursor, + 'ERR_LCH_KEY', + 'Invalid key-period partition' + ) + cursor += count + } + lchAssert(cursor === segmentCount, 'ERR_LCH_KEY', 'Key periods do not cover every segment') +} + +export async function encryptSegmented( + plaintext: Uint8Array, + options: SegmentedEncryptionOptions = {} +): Promise { + const segmentSize = options.segmentSize ?? 4_194_288 + const segmentCount = Math.max(1, Math.ceil(plaintext.length / segmentSize)) + const keyPeriodSegments = options.keyPeriodSegments ?? segmentCount + lchAssert( + Number.isSafeInteger(segmentSize) && + segmentSize > 0 && + Number.isSafeInteger(keyPeriodSegments) && + keyPeriodSegments > 0, + 'ERR_LCH_KEY', + 'Segment and key-period sizes must be positive safe integers' + ) + const random = options.random ?? secureRandom + const descriptor: SegmentedEncryptionDescriptor = { + algorithm: LCH_MECHANISMS.encryption, + encryptionId: random(32), + plaintextLength: plaintext.length, + segmentSize, + segmentCount, + noncePrefix: random(4), + keyPeriods: [] + } + const keys = new Map() + const keyMaterial = new Set() + for (let first = 0; first < segmentCount; first += keyPeriodSegments) { + const cek = random(32) + lchAssert(cek.length === 32, 'ERR_LCH_KEY', 'Random source returned an invalid CEK') + const cekHex = toHex(cek) + lchAssert( + !keyMaterial.has(cekHex), + 'ERR_LCH_KEY', + 'Random source reused a CEK across key periods' + ) + keyMaterial.add(cekHex) + const keyId = await keyIdFor(cek) + descriptor.keyPeriods.push({ + keyId, + firstSegment: first, + segmentCount: Math.min(keyPeriodSegments, segmentCount - first) + }) + keys.set(toHex(keyId), cek) + } + validateEncryptionDescriptor(descriptor) + const records: Uint8Array[] = [] + for (let index = 0; index < segmentCount; index += 1) { + const period = periodFor(index, descriptor.keyPeriods) + const cek = keys.get(toHex(period.keyId)) + lchAssert(cek !== undefined, 'ERR_LCH_KEY', 'Missing CEK during encryption') + const key = await crypto.subtle.importKey('raw', ownedBuffer(cek), 'AES-GCM', false, [ + 'encrypt' + ]) + const segment = plaintext.slice( + index * segmentSize, + Math.min((index + 1) * segmentSize, plaintext.length) + ) + const record = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: ownedBuffer(segmentIv(descriptor.noncePrefix, index)), + additionalData: ownedBuffer(segmentAad(descriptor, index, period.keyId)), + tagLength: 128 + }, + key, + ownedBuffer(segment) + ) + records.push(new Uint8Array(record)) + } + return { ciphertext: concatBytes(...records), descriptor, keys } +} + +export function ciphertextLength(descriptor: SegmentedEncryptionDescriptor): bigint { + return BigInt(descriptor.plaintextLength) + 16n * BigInt(descriptor.segmentCount) +} + +export function recordRange( + descriptor: SegmentedEncryptionDescriptor, + index: number +): readonly [number, number] { + validateEncryptionDescriptor(descriptor) + const segmentSize = toSafeNumber(descriptor.segmentSize, 'segmentSize') + const count = toSafeNumber(descriptor.segmentCount, 'segmentCount') + lchAssert(index >= 0 && index < count, 'ERR_LCH_SELECTION', 'Segment index is out of range') + const start = index * (segmentSize + 16) + const plaintextLength = toSafeNumber(descriptor.plaintextLength, 'plaintextLength') + const plainRecordLength = + index === count - 1 ? plaintextLength - segmentSize * (count - 1) : segmentSize + return [start, start + plainRecordLength + 16] +} + +function selectedSegments(selection: Selection, count: number): Set { + if (selection.type === 'all') return new Set(Array.from({ length: count }, (_, index) => index)) + lchAssert( + selection.type === 'segments', + 'ERR_LCH_SELECTION', + 'Decryption requires all or segment selection' + ) + const result = new Set() + for (const [startValue, endValue] of selection.ranges) { + const start = toSafeNumber(startValue, 'selection start') + const end = toSafeNumber(endValue, 'selection end') + lchAssert(start < end && end <= count, 'ERR_LCH_SELECTION', 'Segment selection is out of range') + for (let index = start; index < end; index += 1) result.add(index) + } + return result +} + +export function keyPeriodsForSelection( + descriptor: SegmentedEncryptionDescriptor, + selection: Selection +): KeyPeriod[] { + validateEncryptionDescriptor(descriptor) + const selected = selectedSegments( + selection, + toSafeNumber(descriptor.segmentCount, 'segmentCount') + ) + return descriptor.keyPeriods.filter(period => { + const first = toSafeNumber(period.firstSegment, 'firstSegment') + const count = toSafeNumber(period.segmentCount, 'segmentCount') + return Array.from(selected).some(index => index >= first && index < first + count) + }) +} + +export function validateKeyGrantsForSelection( + descriptor: SegmentedEncryptionDescriptor, + selection: Selection, + grants: ReadonlyArray<{ keyId: Uint8Array }> +): void { + const expected = keyPeriodsForSelection(descriptor, selection).map(period => toHex(period.keyId)) + const actual = grants.map(grant => toHex(grant.keyId)) + lchAssert( + new Set(actual).size === actual.length, + 'ERR_LCH_KEY', + 'License contains duplicate Key IDs' + ) + lchAssert( + expected.length === actual.length && + expected.every(keyId => actual.includes(keyId)) && + actual.every(keyId => expected.includes(keyId)), + 'ERR_LCH_KEY', + 'License must grant every and only the key periods intersecting its segment selection' + ) +} + +export async function decryptSegmented( + ciphertext: Uint8Array, + descriptor: SegmentedEncryptionDescriptor, + keys: ReadonlyMap, + selection: Selection = ALL_SELECTION +): Promise { + validateEncryptionDescriptor(descriptor) + lchAssert( + BigInt(ciphertext.length) === ciphertextLength(descriptor), + 'ERR_LCH_CONTENT_DIGEST', + 'Ciphertext length mismatch' + ) + const count = toSafeNumber(descriptor.segmentCount, 'segmentCount') + const selected = selectedSegments(selection, count) + const plaintext: Uint8Array[] = [] + for (let index = 0; index < count; index += 1) { + if (!selected.has(index)) continue + const period = periodFor(index, descriptor.keyPeriods) + const cek = keys.get(toHex(period.keyId)) + lchAssert(cek !== undefined, 'ERR_LCH_KEY', `No key grant for segment ${index}`) + const actualKeyId = await keyIdFor(cek) + lchAssert( + toHex(actualKeyId) === toHex(period.keyId), + 'ERR_LCH_KEY', + 'CEK does not match its Key ID' + ) + const [start, end] = recordRange(descriptor, index) + const key = await crypto.subtle.importKey('raw', ownedBuffer(cek), 'AES-GCM', false, [ + 'decrypt' + ]) + try { + const segment = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: ownedBuffer(segmentIv(descriptor.noncePrefix, index)), + additionalData: ownedBuffer(segmentAad(descriptor, index, period.keyId)), + tagLength: 128 + }, + key, + ownedBuffer(ciphertext.slice(start, end)) + ) + plaintext.push(new Uint8Array(segment)) + } catch (error) { + throw new LCHError('ERR_LCH_AUTHENTICATION', `Segment ${index} failed authentication`, { + cause: error + }) + } + } + return concatBytes(...plaintext) +} diff --git a/packages/content/lch/src/endpoints.ts b/packages/content/lch/src/endpoints.ts new file mode 100644 index 000000000..34ef9f156 --- /dev/null +++ b/packages/content/lch/src/endpoints.ts @@ -0,0 +1,161 @@ +import { LCH_LIMITS } from './constants.js' +import { LCHError, lchAssert } from './errors.js' + +export type EndpointClass = 'identity' | 'content' + +export interface EndpointPolicy { + allowLocalOrigins?: readonly string[] + resolve?: (hostname: string) => Promise + connect?: ( + url: URL, + init: RequestInit, + validatedAddresses: readonly string[] + ) => Promise + maximumRedirects?: number +} + +function ipv4Value(address: string): number | undefined { + const parts = address.split('.') + if (parts.length !== 4) return undefined + const octets = parts.map(part => (/^\d{1,3}$/u.test(part) ? Number(part) : -1)) + if (octets.some(value => value < 0 || value > 255)) return undefined + return (((octets[0] * 256 + octets[1]) * 256 + octets[2]) * 256 + octets[3]) >>> 0 +} + +function inV4Range(value: number, start: number, bits: number): boolean { + const shift = 32 - bits + return value >>> shift === start >>> shift +} + +export function isPublicAddress(address: string): boolean { + const v4 = ipv4Value(address) + if (v4 !== undefined) { + const blocked: Array<[number, number]> = [ + [0x00000000, 8], + [0x0a000000, 8], + [0x64400000, 10], + [0x7f000000, 8], + [0xa9fe0000, 16], + [0xac100000, 12], + [0xc0000000, 24], + [0xc0000200, 24], + [0xc0a80000, 16], + [0xc6120000, 15], + [0xc6336400, 24], + [0xcb007100, 24], + [0xe0000000, 4], + [0xf0000000, 4] + ] + return !blocked.some(([start, bits]) => inV4Range(v4, start, bits)) + } + const normalized = address.toLowerCase().replace(/^\[|\]$/gu, '') + if (!normalized.includes(':')) return false + if (normalized === '::' || normalized === '::1') return false + if (/^f[cd][\da-f]{2}:/u.test(normalized) || /^fe[89ab][\da-f]:/u.test(normalized)) return false + if (normalized.startsWith('ff') || normalized.startsWith('2001:db8:')) return false + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/u.exec(normalized)?.[1] + return mapped === undefined ? true : isPublicAddress(mapped) +} + +export async function validateEndpoint(value: string, policy: EndpointPolicy = {}): Promise { + let url: URL + try { + url = new URL(value) + } catch (error) { + throw new LCHError('ERR_LCH_ENDPOINT', 'Endpoint is not an absolute URL', { cause: error }) + } + const localOrigin = policy.allowLocalOrigins?.includes(url.origin) === true + lchAssert( + (url.protocol === 'https:' || (localOrigin && url.protocol === 'http:')) && + url.username === '' && + url.password === '' && + url.hash === '', + 'ERR_LCH_ENDPOINT', + 'Endpoint must be HTTPS without userinfo or fragment' + ) + if (localOrigin) return url + const directAddress = isPublicAddress(url.hostname) + if (/^[\d.]+$/u.test(url.hostname) || url.hostname.includes(':')) { + lchAssert(directAddress, 'ERR_LCH_ENDPOINT', 'Endpoint address is not public') + } + if (directAddress) return url + lchAssert( + policy.resolve !== undefined, + 'ERR_LCH_ENDPOINT', + 'No DNS validation resolver is configured' + ) + const addresses = await policy.resolve(url.hostname) + lchAssert( + addresses.length > 0 && addresses.every(isPublicAddress), + 'ERR_LCH_ENDPOINT', + 'Endpoint DNS result is empty or non-public' + ) + return url +} + +async function validatedAddresses(url: URL, policy: EndpointPolicy): Promise { + if (policy.allowLocalOrigins?.includes(url.origin) === true) return [] + if (isPublicAddress(url.hostname)) return [url.hostname] + lchAssert( + policy.resolve !== undefined, + 'ERR_LCH_ENDPOINT', + 'No DNS validation resolver is configured' + ) + const addresses = await policy.resolve(url.hostname) + lchAssert( + addresses.length > 0 && addresses.every(isPublicAddress), + 'ERR_LCH_ENDPOINT', + 'Endpoint DNS result is empty or non-public' + ) + return addresses +} + +export async function fetchLCH( + input: string, + init: RequestInit = {}, + endpointClass: EndpointClass = 'content', + policy: EndpointPolicy = {} +): Promise { + let url = await validateEndpoint(input, policy) + const origin = url.origin + const maximum = + endpointClass === 'identity' ? 1 : (policy.maximumRedirects ?? LCH_LIMITS.redirects) + for (let redirect = 0; ; redirect += 1) { + const addresses = await validatedAddresses(url, policy) + const request = { ...init, redirect: 'manual' as const } + const localOrigin = policy.allowLocalOrigins?.includes(url.origin) === true + lchAssert( + policy.connect !== undefined || + (addresses.length === 1 && addresses[0] === url.hostname) || + localOrigin, + 'ERR_LCH_ENDPOINT', + 'DNS endpoints require an address-pinning connector' + ) + const response = + policy.connect === undefined + ? await fetch(url, request) + : await policy.connect(url, request, addresses) + if (![301, 302, 303, 307, 308].includes(response.status)) return response + lchAssert(redirect < maximum, 'ERR_LCH_ENDPOINT', 'Endpoint redirect limit exceeded') + const location = response.headers.get('location') + lchAssert(location !== null, 'ERR_LCH_ENDPOINT', 'Redirect omitted Location') + const next = await validateEndpoint(new URL(location, url).href, policy) + if (endpointClass === 'identity') { + lchAssert( + (response.status === 307 || response.status === 308) && next.origin === origin, + 'ERR_LCH_ENDPOINT', + 'Identity endpoint redirect must preserve method and origin' + ) + } else if (next.origin !== url.origin) { + init = { ...init, headers: stripSensitiveHeaders(init.headers) } + } + url = next + } +} + +function stripSensitiveHeaders(input: HeadersInit | undefined): Headers { + const headers = new Headers(input) + for (const name of ['authorization', 'cookie', 'x-bsv-auth', 'x-bsv-payment']) + headers.delete(name) + return headers +} diff --git a/packages/content/lch/src/errors.ts b/packages/content/lch/src/errors.ts new file mode 100644 index 000000000..87d5af6b3 --- /dev/null +++ b/packages/content/lch/src/errors.ts @@ -0,0 +1,40 @@ +export type LCHErrorCode = + | 'ERR_LCH_FRAMING' + | 'ERR_LCH_CBOR' + | 'ERR_LCH_SIGNATURE' + | 'ERR_LCH_AUTHORITY' + | 'ERR_LCH_REVOCATION' + | 'ERR_LCH_ENDPOINT' + | 'ERR_LCH_PROFILE_UNSUPPORTED' + | 'ERR_LCH_POLICY' + | 'ERR_LCH_TERMS' + | 'ERR_LCH_CONTENT_UNAVAILABLE' + | 'ERR_LCH_CONTENT_DIGEST' + | 'ERR_LCH_KEY' + | 'ERR_LCH_AUTHENTICATION' + | 'ERR_LCH_SELECTION' + | 'ERR_LCH_QUOTE' + | 'ERR_LCH_PAYMENT' + | 'ERR_LCH_DELIVERY' + | 'ERR_LCH_LICENSE' + | 'ERR_LCH_PROVENANCE' + | 'ERR_LCH_CYCLE' + +export class LCHError extends Error { + constructor( + public readonly code: LCHErrorCode, + message: string, + options?: ErrorOptions + ) { + super(message, options) + this.name = 'LCHError' + } +} + +export function lchAssert( + condition: unknown, + code: LCHErrorCode, + message: string +): asserts condition { + if (!condition) throw new LCHError(code, message) +} diff --git a/packages/content/lch/src/framing.ts b/packages/content/lch/src/framing.ts new file mode 100644 index 000000000..fdd5ea634 --- /dev/null +++ b/packages/content/lch/src/framing.ts @@ -0,0 +1,56 @@ +import { decodeDeterministicCbor, encodeDeterministicCbor } from './cbor.js' +import { LCH_LIMITS, LCH_MAGIC } from './constants.js' +import { lchAssert } from './errors.js' +import { concatBytes, readUint64be, uint64be } from './hash.js' +import type { LCHValue } from './types.js' + +export interface ParsedLCH { + header: Record + headerBytes: Uint8Array + ciphertext?: Uint8Array +} + +export function frameLCH(header: Record, ciphertext?: Uint8Array): Uint8Array { + const headerBytes = encodeDeterministicCbor(header) + lchAssert( + headerBytes.length <= LCH_LIMITS.headerBytes, + 'ERR_LCH_FRAMING', + 'LCH header limit exceeded' + ) + return concatBytes( + LCH_MAGIC, + uint64be(headerBytes.length), + headerBytes, + ciphertext ?? new Uint8Array() + ) +} + +export function parseLCH(bytes: Uint8Array): ParsedLCH { + lchAssert(bytes.length >= 12, 'ERR_LCH_FRAMING', 'Truncated LCH prefix') + for (let index = 0; index < LCH_MAGIC.length; index += 1) { + lchAssert(bytes[index] === LCH_MAGIC[index], 'ERR_LCH_FRAMING', 'Invalid LCH magic or version') + } + const length = readUint64be(bytes.slice(4, 12)) + lchAssert( + length <= BigInt(LCH_LIMITS.headerBytes), + 'ERR_LCH_FRAMING', + 'LCH header limit exceeded' + ) + lchAssert(length <= BigInt(bytes.length - 12), 'ERR_LCH_FRAMING', 'Truncated LCH header') + const end = 12 + Number(length) + const headerBytes = bytes.slice(12, end) + const decoded = decodeDeterministicCbor(headerBytes) + lchAssert( + decoded !== null && + typeof decoded === 'object' && + !Array.isArray(decoded) && + !(decoded instanceof Uint8Array), + 'ERR_LCH_FRAMING', + 'LCH header must be a map' + ) + return { + header: decoded, + headerBytes, + ...(end < bytes.length ? { ciphertext: bytes.slice(end) } : {}) + } +} diff --git a/packages/content/lch/src/hash.ts b/packages/content/lch/src/hash.ts new file mode 100644 index 000000000..a084a286d --- /dev/null +++ b/packages/content/lch/src/hash.ts @@ -0,0 +1,80 @@ +import { LCH_OBJECT_TYPES } from './constants.js' +import { encodeDeterministicCbor } from './cbor.js' +import { lchAssert } from './errors.js' +import type { LCHObjectType, LCHValue } from './types.js' + +const textEncoder = new TextEncoder() + +export function concatBytes(...parts: readonly Uint8Array[]): Uint8Array { + const length = parts.reduce((total, part) => total + part.length, 0) + const result = new Uint8Array(length) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +export async function sha256(bytes: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes.slice().buffer)) +} + +export function toHex(bytes: Uint8Array): string { + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') +} + +export function fromHex(value: string): Uint8Array { + lchAssert(/^(?:[\da-f]{2})+$/u.test(value), 'ERR_LCH_CBOR', 'Invalid lowercase hexadecimal') + return Uint8Array.from(value.match(/../gu) ?? [], pair => Number.parseInt(pair, 16)) +} + +export function toBase64Url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) binary += String.fromCodePoint(byte) + let encoded = btoa(binary).replaceAll('+', '-').replaceAll('/', '_') + while (encoded.endsWith('=')) encoded = encoded.slice(0, -1) + return encoded +} + +export function fromBase64Url(value: string): Uint8Array { + lchAssert(/^[\w-]*$/u.test(value), 'ERR_LCH_CBOR', 'Invalid unpadded base64url') + const standard = value.replaceAll('-', '+').replaceAll('_', '/') + const binary = atob(standard.padEnd(Math.ceil(standard.length / 4) * 4, '=')) + return Uint8Array.from(binary, character => character.codePointAt(0) ?? 0) +} + +export function objectPreimage(type: LCHObjectType, body: LCHValue): Uint8Array { + lchAssert(LCH_OBJECT_TYPES.includes(type), 'ERR_LCH_CBOR', `Unsupported LCH object type: ${type}`) + return concatBytes(textEncoder.encode(`LCH/${type}/1\0`), encodeDeterministicCbor(body)) +} + +export async function objectId(type: LCHObjectType, body: LCHValue): Promise { + return sha256(objectPreimage(type, body)) +} + +export async function objectIri(type: LCHObjectType, body: LCHValue): Promise { + return `lch:${type}:sha256:${toHex(await objectId(type, body))}` +} + +export function uint64be(value: number | bigint): Uint8Array { + let remaining = typeof value === 'number' ? BigInt(value) : value + lchAssert( + remaining >= 0n && remaining <= 0xffffffffffffffffn, + 'ERR_LCH_CBOR', + 'uint64 out of range' + ) + const result = new Uint8Array(8) + for (let index = 7; index >= 0; index -= 1) { + result[index] = Number(remaining & 0xffn) + remaining >>= 8n + } + return result +} + +export function readUint64be(bytes: Uint8Array): bigint { + lchAssert(bytes.length === 8, 'ERR_LCH_FRAMING', 'Expected eight-byte uint64') + let result = 0n + for (const byte of bytes) result = (result << 8n) | BigInt(byte) + return result +} diff --git a/packages/content/lch/src/http.ts b/packages/content/lch/src/http.ts new file mode 100644 index 000000000..d8c747c43 --- /dev/null +++ b/packages/content/lch/src/http.ts @@ -0,0 +1,583 @@ +import { decodeDeterministicCbor, encodeDeterministicCbor } from './cbor.js' +import { LCH_LIMITS } from './constants.js' +import { fetchLCH, type EndpointPolicy } from './endpoints.js' +import { LCHError, lchAssert, type LCHErrorCode } from './errors.js' +import type { PaymentCompletion } from './acquisition.js' +import type { + AuthorizedOutputEvidence, + PaymentDeliveryStoreRequest, + StoredPaymentDelivery, + TransactionEvidenceRequest +} from './settlement.js' +import type { LCHValue, SignedObject } from './types.js' + +export const LCH_CBOR_MEDIA_TYPE = 'application/vnd.bsv.lch+cbor' + +export type LCHHttpMessageType = + | 'license-request-preflight' + | 'license-request' + | 'quote' + | 'payment-demand' + | 'payment-readiness' + | 'payment-authorization-request' + | 'payment-authorization' + | 'payment-delivery' + | 'payment-receipt' + | 'payment-delivery-store' + | 'payment-delivery-retrieval' + | 'payment-delivery-stored' + | 'payment-delivery-ack' + | 'transaction-evidence-request' + | 'transaction-evidence' + | 'payment-completion' + | 'license' + | 'license-recovery' + | 'error' + +export interface LCHHttpHandlers { + preflightLicense?(request: SignedObject): Promise + quote?(request: SignedObject): Promise + preflightDemand?(demand: SignedObject): Promise + authorizePayment?(demand: SignedObject): Promise + paymentDelivery?(delivery: SignedObject): Promise + storeDelivery?(request: PaymentDeliveryStoreRequest): Promise + retrieveDelivery?(request: SignedObject): Promise + attestTransaction?(request: TransactionEvidenceRequest): Promise + complete?(completion: PaymentCompletion): Promise + recover?(requestId: Uint8Array): Promise +} + +export interface LCHHttpServerOptions { + handlers: LCHHttpHandlers + maximumRequestBytes?: number + allowOrigin?: string +} + +export class LCHHttpServer { + private readonly maximumRequestBytes: number + + constructor(private readonly options: LCHHttpServerOptions) { + this.maximumRequestBytes = options.maximumRequestBytes ?? LCH_LIMITS.headerBytes + lchAssert( + Number.isSafeInteger(this.maximumRequestBytes) && this.maximumRequestBytes > 0, + 'ERR_LCH_FRAMING', + 'HTTP request limit is invalid' + ) + } + + async handle(request: Request): Promise { + const cors = this.corsHeaders() + if (request.method === 'OPTIONS') + return new Response(null, { + status: 204, + headers: { + ...cors, + 'access-control-allow-headers': 'content-type', + 'access-control-allow-methods': 'POST, OPTIONS' + } + }) + if (request.method !== 'POST') return errorResponse(405, 'ERR_LCH_ENDPOINT', cors) + try { + const type = messageType(request.headers.get('content-type')) + const value = decodeDeterministicCbor( + await boundedBytes(request, this.maximumRequestBytes, 'ERR_LCH_FRAMING') + ) + return await this.dispatch(type, value, cors) + } catch (error) { + const code = error instanceof LCHError ? error.code : 'ERR_LCH_DELIVERY' + return errorResponse(errorStatus(code), code, cors) + } + } + + private async dispatch( + type: LCHHttpMessageType, + value: LCHValue, + headers: Record + ): Promise { + if (type === 'license-request-preflight') { + await required(this.options.handlers.preflightLicense, type)(signed(value)) + return new Response(null, { status: 204, headers }) + } + if (type === 'license-request') { + const quote = await required(this.options.handlers.quote, type)(signed(value)) + return cborResponse('quote', quote as unknown as LCHValue, 200, headers) + } + if (type === 'payment-demand') { + const readiness = await required(this.options.handlers.preflightDemand, type)(signed(value)) + return cborResponse('payment-readiness', readiness as unknown as LCHValue, 200, headers) + } + if (type === 'payment-authorization-request') { + const authorization = await required( + this.options.handlers.authorizePayment, + type + )(signed(value)) + return cborResponse( + 'payment-authorization', + authorization as unknown as LCHValue, + 200, + headers + ) + } + if (type === 'payment-delivery') { + const receipt = await required(this.options.handlers.paymentDelivery, type)(signed(value)) + return cborResponse('payment-receipt', receipt as unknown as LCHValue, 200, headers) + } + if (type === 'payment-delivery-store') { + const acknowledgement = await required( + this.options.handlers.storeDelivery, + type + )(deliveryStoreRequest(value)) + return cborResponse( + 'payment-delivery-ack', + acknowledgement as unknown as LCHValue, + 200, + headers + ) + } + if (type === 'payment-delivery-retrieval') { + const stored = await required(this.options.handlers.retrieveDelivery, type)(signed(value)) + if (stored === undefined) return errorResponse(404, 'ERR_LCH_DELIVERY', headers) + return cborResponse('payment-delivery-stored', stored as unknown as LCHValue, 200, headers) + } + if (type === 'transaction-evidence-request') { + const evidence = await required( + this.options.handlers.attestTransaction, + type + )(transactionEvidenceRequest(value)) + return cborResponse('transaction-evidence', evidence as unknown as LCHValue, 200, headers) + } + if (type === 'payment-completion') { + const license = await required(this.options.handlers.complete, type)(completion(value)) + return cborResponse('license', license as unknown as LCHValue, 200, headers) + } + if (type === 'license-recovery') { + const license = await required(this.options.handlers.recover, type)(recoveryRequest(value)) + if (license === undefined) return errorResponse(404, 'ERR_LCH_LICENSE', headers) + return cborResponse('license', license as unknown as LCHValue, 200, headers) + } + return errorResponse(415, 'ERR_LCH_PROFILE_UNSUPPORTED', headers) + } + + private corsHeaders(): Record { + return { + 'access-control-allow-origin': this.options.allowOrigin ?? '*', + 'access-control-expose-headers': 'content-type', + 'cache-control': 'no-store' + } + } +} + +function errorStatus(code: LCHErrorCode): number { + if (code === 'ERR_LCH_PAYMENT') return 402 + if (code === 'ERR_LCH_ENDPOINT') return 400 + return 422 +} + +export interface LCHHttpClientOptions { + endpointPolicy?: EndpointPolicy + maximumResponseBytes?: number +} + +export class LCHHttpAcquisitionClient { + private readonly maximumResponseBytes: number + + constructor(private readonly options: LCHHttpClientOptions = {}) { + this.maximumResponseBytes = options.maximumResponseBytes ?? LCH_LIMITS.headerBytes + lchAssert( + Number.isSafeInteger(this.maximumResponseBytes) && this.maximumResponseBytes > 0, + 'ERR_LCH_FRAMING', + 'HTTP response limit is invalid' + ) + } + + async preflightLicense(endpoint: string, request: SignedObject): Promise { + await this.post(endpoint, 'license-request-preflight', request as unknown as LCHValue, 204) + } + + async quote(endpoint: string, request: SignedObject): Promise { + return signed( + await this.post(endpoint, 'license-request', request as unknown as LCHValue, 200, 'quote') + ) + } + + async preflightDemand(endpoint: string, demand: SignedObject): Promise { + return signed( + await this.post( + endpoint, + 'payment-demand', + demand as unknown as LCHValue, + 200, + 'payment-readiness' + ) + ) + } + + async deliver(endpoint: string, delivery: SignedObject): Promise { + return signed( + await this.post( + endpoint, + 'payment-delivery', + delivery as unknown as LCHValue, + 200, + 'payment-receipt' + ) + ) + } + + async authorizePayment(endpoint: string, demand: SignedObject): Promise { + return signed( + await this.post( + endpoint, + 'payment-authorization-request', + demand as unknown as LCHValue, + 200, + 'payment-authorization' + ) + ) + } + + async storeDelivery( + endpoint: string, + authorization: SignedObject, + delivery: SignedObject + ): Promise { + return signed( + await this.post( + endpoint, + 'payment-delivery-store', + { authorization, delivery } as unknown as LCHValue, + 200, + 'payment-delivery-ack' + ) + ) + } + + async attestTransaction( + endpoint: string, + authorization: SignedObject, + atomicBeef: Uint8Array + ): Promise { + return signed( + await this.post( + endpoint, + 'transaction-evidence-request', + { authorization, atomicBeef } as unknown as LCHValue, + 200, + 'transaction-evidence' + ) + ) + } + + async retrieveDelivery( + endpoint: string, + request: SignedObject + ): Promise { + const response = await this.request( + endpoint, + 'payment-delivery-retrieval', + request as unknown as LCHValue + ) + if (response.status === 404) return undefined + await requireResponse(response, 200, 'payment-delivery-stored', this.maximumResponseBytes) + return storedPaymentDelivery( + decodeDeterministicCbor( + await boundedBytes(response, this.maximumResponseBytes, 'ERR_LCH_DELIVERY') + ) + ) + } + + async complete(endpoint: string, value: PaymentCompletion): Promise { + return signed( + await this.post(endpoint, 'payment-completion', value as unknown as LCHValue, 200, 'license') + ) + } + + async recover(endpoint: string, requestId: Uint8Array): Promise { + const response = await this.request(endpoint, 'license-recovery', { requestId }) + if (response.status === 404) return undefined + await requireResponse(response, 200, 'license', this.maximumResponseBytes) + return signed( + decodeDeterministicCbor( + await boundedBytes(response, this.maximumResponseBytes, 'ERR_LCH_DELIVERY') + ) + ) + } + + private async post( + endpoint: string, + type: LCHHttpMessageType, + value: LCHValue, + status: number, + responseType?: LCHHttpMessageType + ): Promise { + const response = await this.request(endpoint, type, value) + await requireResponse(response, status, responseType, this.maximumResponseBytes) + if (status === 204) return null + return decodeDeterministicCbor( + await boundedBytes(response, this.maximumResponseBytes, 'ERR_LCH_DELIVERY') + ) + } + + private request(endpoint: string, type: LCHHttpMessageType, value: LCHValue): Promise { + return fetchLCH( + endpoint, + { + method: 'POST', + headers: { 'content-type': mediaType(type), accept: LCH_CBOR_MEDIA_TYPE }, + body: encodeDeterministicCbor(value).slice().buffer + }, + 'identity', + this.options.endpointPolicy + ) + } +} + +function mediaType(type: LCHHttpMessageType): string { + return `${LCH_CBOR_MEDIA_TYPE}; type=${type}` +} + +function messageType(value: string | null): LCHHttpMessageType { + lchAssert(value !== null, 'ERR_LCH_ENDPOINT', 'Content-Type is absent') + const match = /^application\/vnd\.bsv\.lch\+cbor\s*;\s*type=([a-z-]+)$/iu.exec(value) + lchAssert(match !== null, 'ERR_LCH_ENDPOINT', 'Content-Type is not an LCH HTTP message') + return match[1].toLowerCase() as LCHHttpMessageType +} + +async function requireResponse( + response: Response, + status: number, + type: LCHHttpMessageType | undefined, + maximum: number +): Promise { + if (response.status !== status) { + let code: LCHErrorCode = response.status === 402 ? 'ERR_LCH_PAYMENT' : 'ERR_LCH_DELIVERY' + try { + if (messageType(response.headers.get('content-type')) === 'error') { + const value = decodeDeterministicCbor( + await boundedBytes(response, maximum, 'ERR_LCH_DELIVERY') + ) + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array) && + typeof value.code === 'string' && + isErrorCode(value.code) + ) + code = value.code + } + } catch { + // Preserve the transport-level error when an error envelope is itself malformed. + } + throw new LCHError(code, `LCH endpoint returned ${response.status}`) + } + if (type !== undefined) + lchAssert( + messageType(response.headers.get('content-type')) === type, + 'ERR_LCH_DELIVERY', + 'LCH response type does not match the operation' + ) +} + +function isErrorCode(value: string): value is LCHErrorCode { + return new Set([ + 'ERR_LCH_FRAMING', + 'ERR_LCH_CBOR', + 'ERR_LCH_SIGNATURE', + 'ERR_LCH_AUTHORITY', + 'ERR_LCH_REVOCATION', + 'ERR_LCH_ENDPOINT', + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'ERR_LCH_POLICY', + 'ERR_LCH_TERMS', + 'ERR_LCH_CONTENT_UNAVAILABLE', + 'ERR_LCH_CONTENT_DIGEST', + 'ERR_LCH_KEY', + 'ERR_LCH_AUTHENTICATION', + 'ERR_LCH_SELECTION', + 'ERR_LCH_QUOTE', + 'ERR_LCH_PAYMENT', + 'ERR_LCH_DELIVERY', + 'ERR_LCH_LICENSE', + 'ERR_LCH_PROVENANCE', + 'ERR_LCH_CYCLE' + ]).has(value) +} + +async function boundedBytes( + message: Request | Response, + maximum: number, + code: 'ERR_LCH_DELIVERY' | 'ERR_LCH_FRAMING' +): Promise { + const declared = message.headers.get('content-length') + if (declared !== null) + lchAssert( + /^\d+$/u.test(declared) && Number(declared) <= maximum, + code, + 'LCH HTTP body exceeds its limit' + ) + lchAssert(message.body !== null, code, 'LCH HTTP body is absent') + const reader = message.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.length + lchAssert(total <= maximum, code, 'LCH HTTP body exceeds its limit') + chunks.push(value) + } + const result = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.length + } + return result +} + +function signed(value: LCHValue): SignedObject { + lchAssert( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array), + 'ERR_LCH_FRAMING', + 'Signed Object is not a map' + ) + const body = value.body + const signatures = value.signatures + lchAssert( + body !== null && + typeof body === 'object' && + !Array.isArray(body) && + !(body instanceof Uint8Array) && + Array.isArray(signatures) && + signatures.length > 0 && + signatures.every(item => item instanceof Uint8Array), + 'ERR_LCH_FRAMING', + 'Signed Object has invalid body or signatures' + ) + return { body: body as Record, signatures: signatures as Uint8Array[] } +} + +function completion(value: LCHValue): PaymentCompletion { + lchAssert( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array), + 'ERR_LCH_FRAMING', + 'Payment Completion is not a map' + ) + lchAssert( + value.atomicBeef instanceof Uint8Array && Array.isArray(value.receipts), + 'ERR_LCH_PAYMENT', + 'Payment Completion is incomplete' + ) + const receipts = value.receipts.map(signed) + const authorizedOutputs = + value.authorizedOutputs === undefined + ? undefined + : authorizedOutputArray(value.authorizedOutputs) + lchAssert( + receipts.length + (authorizedOutputs?.length ?? 0) > 0, + 'ERR_LCH_PAYMENT', + 'Payment Completion has no settlement proofs' + ) + return { + request: signed(value.request), + quote: signed(value.quote), + atomicBeef: value.atomicBeef, + receipts, + ...(authorizedOutputs === undefined ? {} : { authorizedOutputs }) + } +} + +function transactionEvidenceRequest(value: LCHValue): TransactionEvidenceRequest { + const map = record(value, 'Transaction evidence request') + lchAssert( + map.atomicBeef instanceof Uint8Array && map.atomicBeef.length > 0, + 'ERR_LCH_PAYMENT', + 'Transaction evidence request has no Atomic BEEF' + ) + return { authorization: signed(map.authorization), atomicBeef: map.atomicBeef } +} + +function deliveryStoreRequest(value: LCHValue): PaymentDeliveryStoreRequest { + const map = record(value, 'Payment Delivery store request') + return { authorization: signed(map.authorization), delivery: signed(map.delivery) } +} + +function storedPaymentDelivery(value: LCHValue): StoredPaymentDelivery { + const map = record(value, 'Stored Payment Delivery') + return { + authorization: signed(map.authorization), + delivery: signed(map.delivery), + deliveryAcknowledgement: signed(map.deliveryAcknowledgement) + } +} + +function authorizedOutputArray(value: LCHValue): AuthorizedOutputEvidence[] { + lchAssert(Array.isArray(value), 'ERR_LCH_PAYMENT', 'Authorized-output evidence is not an array') + return value.map(item => { + const map = record(item, 'Authorized-output evidence') + return { + authorization: signed(map.authorization), + delivery: signed(map.delivery), + transactionEvidence: signed(map.transactionEvidence), + deliveryAcknowledgement: signed(map.deliveryAcknowledgement) + } + }) +} + +function record(value: LCHValue | undefined, name: string): Record { + lchAssert( + value !== undefined && + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array), + 'ERR_LCH_FRAMING', + `${name} is not a map` + ) + return value +} + +function recoveryRequest(value: LCHValue): Uint8Array { + lchAssert( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array) && + value.requestId instanceof Uint8Array && + value.requestId.length === 32, + 'ERR_LCH_LICENSE', + 'License recovery request is invalid' + ) + return value.requestId +} + +function required unknown>( + handler: T | undefined, + type: string +): T { + lchAssert(handler !== undefined, 'ERR_LCH_PROFILE_UNSUPPORTED', `${type} is unsupported`) + return handler +} + +function cborResponse( + type: LCHHttpMessageType, + value: LCHValue, + status: number, + headers: Record +): Response { + return new Response(encodeDeterministicCbor(value).slice().buffer, { + status, + headers: { ...headers, 'content-type': mediaType(type) } + }) +} + +function errorResponse(status: number, code: string, headers: Record): Response { + return cborResponse('error', { code }, status, headers) +} diff --git a/packages/content/lch/src/index.ts b/packages/content/lch/src/index.ts new file mode 100644 index 000000000..e5f6adabd --- /dev/null +++ b/packages/content/lch/src/index.ts @@ -0,0 +1,27 @@ +export * from './constants.js' +export * from './errors.js' +export * from './types.js' +export * from './cbor.js' +export * from './hash.js' +export * from './framing.js' +export * from './encryption.js' +export * from './selection.js' +export * from './payment.js' +export * from './walletPayment.js' +export * from './composition.js' +export * from './signatures.js' +export * from './objects.js' +export * from './keyDelivery.js' +export * from './endpoints.js' +export * from './authority.js' +export * from './policy.js' +export * from './storage.js' +export * from './time.js' +export * from './profiles.js' +export * from './c2pa.js' +export * from './core.js' +export * from './acquisition.js' +export * from './paymentReceiver.js' +export * from './settlement.js' +export * from './http.js' +export * from './multipayClient.js' diff --git a/packages/content/lch/src/keyDelivery.ts b/packages/content/lch/src/keyDelivery.ts new file mode 100644 index 000000000..7f7a2c772 --- /dev/null +++ b/packages/content/lch/src/keyDelivery.ts @@ -0,0 +1,108 @@ +import { Utils, type WalletInterface } from '@bsv/sdk' +import { LCHError, lchAssert } from './errors.js' +import { concatBytes, fromHex, toHex } from './hash.js' +import { keyIdFor } from './encryption.js' + +const BRC78_VERSION = Uint8Array.of(0x42, 0x42, 0x10, 0x33) +const ENCRYPTION_PROTOCOL = [2, 'message encryption'] as const + +function secureRandom(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) +} + +export class WalletBRC78KeyDelivery { + private readonly issuedMessageKeyIds = new Set() + + constructor( + private readonly wallet: Pick, + private readonly random: (length: number) => Uint8Array = secureRandom + ) {} + + async deliver(recipient: string, keyId: Uint8Array, cek: Uint8Array): Promise { + lchAssert( + keyId.length === 32 && cek.length === 32, + 'ERR_LCH_KEY', + 'Key ID and CEK must contain 32 bytes' + ) + lchAssert( + toHex(await keyIdFor(cek)) === toHex(keyId), + 'ERR_LCH_KEY', + 'CEK does not match Key ID' + ) + const sender = fromHex((await this.wallet.getPublicKey({ identityKey: true })).publicKey) + const recipientBytes = fromHex(recipient) + lchAssert( + recipientBytes.length === 33, + 'ERR_LCH_KEY', + 'Recipient identity key must be compressed' + ) + const messageKeyId = this.random(32) + lchAssert( + messageKeyId.length === 32, + 'ERR_LCH_KEY', + 'Random source returned invalid BRC-78 Key ID' + ) + const messageKeyIdHex = toHex(messageKeyId) + lchAssert( + !this.issuedMessageKeyIds.has(messageKeyIdHex), + 'ERR_LCH_KEY', + 'Random source reused a BRC-78 message Key ID' + ) + this.issuedMessageKeyIds.add(messageKeyIdHex) + const { ciphertext } = await this.wallet.encrypt({ + plaintext: Array.from(concatBytes(keyId, cek)), + protocolID: [...ENCRYPTION_PROTOCOL], + keyID: Utils.toBase64(Array.from(messageKeyId)), + counterparty: recipient + }) + return concatBytes( + BRC78_VERSION, + sender, + recipientBytes, + messageKeyId, + Uint8Array.from(ciphertext) + ) + } + + async recover(payload: Uint8Array): Promise<{ keyId: Uint8Array; cek: Uint8Array }> { + lchAssert(payload.length > 102, 'ERR_LCH_KEY', 'Truncated BRC-78 payload') + lchAssert( + BRC78_VERSION.every((byte, index) => payload[index] === byte), + 'ERR_LCH_KEY', + 'Invalid BRC-78 version' + ) + const sender = payload.slice(4, 37) + const recipient = payload.slice(37, 70) + const identity = fromHex((await this.wallet.getPublicKey({ identityKey: true })).publicKey) + lchAssert( + toHex(recipient) === toHex(identity), + 'ERR_LCH_KEY', + 'BRC-78 payload is addressed to another identity' + ) + const messageKeyId = payload.slice(70, 102) + let plaintext: number[] + try { + ;({ plaintext } = await this.wallet.decrypt({ + ciphertext: Array.from(payload.slice(102)), + protocolID: [...ENCRYPTION_PROTOCOL], + keyID: Utils.toBase64(Array.from(messageKeyId)), + counterparty: toHex(sender) + })) + } catch (error) { + throw new LCHError('ERR_LCH_KEY', 'BRC-78 key recovery failed', { cause: error }) + } + lchAssert( + plaintext.length === 64, + 'ERR_LCH_KEY', + 'BRC-78 LCH plaintext must contain Key ID and CEK' + ) + const keyId = Uint8Array.from(plaintext.slice(0, 32)) + const cek = Uint8Array.from(plaintext.slice(32)) + lchAssert( + toHex(await keyIdFor(cek)) === toHex(keyId), + 'ERR_LCH_KEY', + 'Recovered CEK does not match Key ID' + ) + return { keyId, cek } + } +} diff --git a/packages/content/lch/src/multipayClient.ts b/packages/content/lch/src/multipayClient.ts new file mode 100644 index 000000000..40d1b9a31 --- /dev/null +++ b/packages/content/lch/src/multipayClient.ts @@ -0,0 +1,537 @@ +import { Transaction, type AtomicBEEF, type WalletInterface } from '@bsv/sdk' +import { + LCHBuyer, + validateLicenseRequest, + validatePaymentDemand, + validatePaymentReceipt, + validatePaymentReadiness, + validateQuote, + type LicenseRequestOptions, + type PaymentCompletion +} from './acquisition.js' +import { LCHHttpAcquisitionClient, type LCHHttpClientOptions } from './http.js' +import { fromHex, objectId, toHex } from './hash.js' +import { LCH_SETTLEMENT_PROFILES } from './constants.js' +import { createMultipayTransaction } from './walletPayment.js' +import { PublicBRC77Verifier, WalletBRC77Signer } from './signatures.js' +import { verifySignedObject } from './objects.js' +import { + validateAuthorizedOutputEvidence, + validateDeliveryAcknowledgement, + validatePaymentAuthorization, + validateTransactionEvidence, + type AuthorizedOutputEvidence +} from './settlement.js' +import type { LCHSigner, LCHTransactionState, LCHValue, SignedObject } from './types.js' + +export interface LCHMultipayPlan { + request: SignedObject + requestId: Uint8Array + quote: SignedObject + demands: SignedObject[] + readiness: SignedObject[] + authorizations: SignedObject[] + issuer: Uint8Array + endpoint: string + totalSatoshis: bigint + expiresAt: bigint + recoveryUntil: bigint +} + +export interface LCHMultipayDelivery { + demandId: Uint8Array + payee: Uint8Array + endpoint: string + delivery: SignedObject +} + +export type LCHMultipaySettlement = + | { type: 'receipt'; receipt: SignedObject } + | { type: 'authorized-output'; evidence: AuthorizedOutputEvidence } + +export interface LCHFundedMultipay { + plan: LCHMultipayPlan + atomicBeef: Uint8Array + deliveries: LCHMultipayDelivery[] + transactionState: Extract +} + +export interface LCHMultipayBuyerOptions extends LCHHttpClientOptions { + now?: () => bigint + transport?: LCHAcquisitionTransport +} + +/** + * Transport boundary for acquisition coordination. + * + * The signed Offer endpoint and every signed Payment Demand endpoint are + * independent destinations. HTTP is the default BRC-170 binding, while an + * application can supply a message-box or other registered binding without + * changing the signed objects or the recovery-safe payment workflow. + */ +export interface LCHAcquisitionTransport { + preflightLicense(endpoint: string, request: SignedObject): Promise + quote(endpoint: string, request: SignedObject): Promise + preflightDemand(endpoint: string, demand: SignedObject): Promise + authorizePayment(endpoint: string, demand: SignedObject): Promise + deliver(endpoint: string, delivery: SignedObject): Promise + storeDelivery( + endpoint: string, + authorization: SignedObject, + delivery: SignedObject + ): Promise + attestTransaction( + endpoint: string, + authorization: SignedObject, + atomicBeef: Uint8Array + ): Promise + complete(endpoint: string, completion: PaymentCompletion): Promise + recover(endpoint: string, requestId: Uint8Array): Promise +} + +/** A complete non-custodial BRC-170 multipay buyer workflow. */ +export class LCHMultipayBuyer { + private readonly buyer: LCHBuyer + private readonly transport: LCHAcquisitionTransport + private readonly now: () => bigint + private readonly allowInsecureLocalOrigins: readonly string[] + + constructor( + private readonly wallet: Pick, + private readonly signer: LCHSigner, + options: LCHMultipayBuyerOptions = {} + ) { + this.buyer = new LCHBuyer(signer) + this.transport = options.transport ?? new LCHHttpAcquisitionClient(options) + this.now = options.now ?? (() => BigInt(Math.floor(Date.now() / 1000))) + this.allowInsecureLocalOrigins = options.endpointPolicy?.allowLocalOrigins ?? [] + } + + static async create( + wallet: WalletInterface, + options: LCHMultipayBuyerOptions = {} + ): Promise { + return new LCHMultipayBuyer(wallet, await WalletBRC77Signer.create({ wallet }), options) + } + + createRequest(options: LicenseRequestOptions): Promise { + return this.buyer.createRequest(options) + } + + async quote( + endpoint: string, + request: SignedObject, + issuer: Uint8Array + ): Promise { + const requestId = await validateLicenseRequest(request) + await this.transport.preflightLicense(endpoint, request) + const quote = await this.transport.quote(endpoint, request) + await validateQuote(quote, request, issuer, undefined, { + allowInsecureLocalOrigins: this.allowInsecureLocalOrigins + }) + const demands = signedArray(quote.body.demands) + const readiness = await this.obtainReadiness(demands) + const authorizations = await this.obtainAuthorizations(demands) + let totalSatoshis = 0n + for (const demand of demands) { + await validatePaymentDemand(demand, undefined, { + allowInsecureLocalOrigins: this.allowInsecureLocalOrigins + }) + equal(demand.body.requestId, requestId, 'Demand Request ID') + totalSatoshis += uint(demand.body.satoshis, 'Demand amount') + } + if (totalSatoshis !== uint(quote.body.totalSatoshis, 'Quote total')) + throw new Error('Quote total does not equal its Payment Demands') + return { + request, + requestId, + quote, + demands, + readiness, + authorizations, + issuer, + endpoint, + totalSatoshis, + expiresAt: uint(quote.body.expiresAt, 'Quote expiry'), + recoveryUntil: uint(quote.body.recoveryUntil, 'Quote recovery deadline') + } + } + + async createPayment(plan: LCHMultipayPlan): Promise { + const now = this.now() + if (now >= plan.expiresAt) + throw new Error('The signed Quote expired before transaction creation') + await this.validatePlanReadiness(plan.demands, plan.readiness, now) + const authorizationByDemand = await this.validatePlanAuthorizations( + plan.demands, + plan.authorizations, + now + ) + const demands = await Promise.all( + plan.demands.map(async demand => ({ + demand, + demandId: await objectId('payment-demand', demand.body), + payee: memberBytes(demand.body, 'payee', 33), + satoshis: uint(demand.body.satoshis, 'Demand amount'), + derivationPrefix: memberBytes(demand.body, 'derivationPrefix', 32), + dutyUid: memberString(demand.body, 'dutyUid'), + authorization: authorizationByDemand.get( + toHex(await objectId('payment-demand', demand.body)) + ) + })) + ) + const payment = await createMultipayTransaction( + this.wallet, + demands.map(({ demand: _demand, authorization, ...item }) => ({ + ...item, + ...(authorization === undefined + ? {} + : { + authorizedOutput: { + derivationSuffix: memberBytes(authorization.body, 'derivationSuffix', 32), + lockingScript: memberBytesAny(authorization.body, 'lockingScript') + } + }) + })) + ) + const deliveries: LCHMultipayDelivery[] = [] + for (const remittance of payment.remittances) { + const item = demands.find(demand => toHex(demand.demandId) === toHex(remittance.demandId)) + if (item === undefined) throw new Error('Wallet returned an unknown remittance') + const delivery = await this.buyer.createPaymentDelivery({ + demandId: item.demandId, + requestId: plan.requestId, + atomicBeef: payment.atomicBeef, + outputIndex: remittance.outputIndex, + derivationPrefix: remittance.derivationPrefix, + derivationSuffix: remittance.derivationSuffix + }) + deliveries.push({ + demandId: item.demandId, + payee: item.payee, + endpoint: memberString(item.demand.body, 'endpoint'), + delivery + }) + } + return { + plan, + atomicBeef: payment.atomicBeef, + deliveries, + transactionState: payment.transactionState + } + } + + async refreshReadiness(plan: LCHMultipayPlan): Promise { + if (this.now() >= plan.expiresAt) + throw new Error('The signed Quote expired before readiness refresh') + return { ...plan, readiness: await this.obtainReadiness(plan.demands) } + } + + async deliver(payment: LCHFundedMultipay, item: LCHMultipayDelivery): Promise { + const receipt = await this.transport.deliver(item.endpoint, item.delivery) + await validatePaymentReceipt(receipt) + equal(receipt.body.demandId, item.demandId, 'Receipt Demand ID') + equal(receipt.body.requestId, payment.plan.requestId, 'Receipt Request ID') + equal(receipt.body.payee, item.payee, 'Receipt Payee') + const transaction = Transaction.fromAtomicBEEF(payment.atomicBeef as AtomicBEEF) + equal(receipt.body.txid, fromHex(transaction.id('hex')), 'Receipt transaction ID') + if ( + uint(receipt.body.outputIndex, 'Receipt output index') !== + uint(item.delivery.body.outputIndex, 'Delivery output index') + ) + throw new Error('Receipt output index does not match the Payment Delivery') + const demand = await demandById(payment.plan.demands, item.demandId) + if ( + uint(receipt.body.satoshis, 'Receipt amount') !== uint(demand.body.satoshis, 'Demand amount') + ) + throw new Error('Receipt amount does not match the Payment Demand') + return receipt + } + + async complete( + payment: LCHFundedMultipay, + receipts: readonly SignedObject[], + authorizedOutputs: readonly AuthorizedOutputEvidence[] = [] + ): Promise { + if (receipts.length + authorizedOutputs.length !== payment.deliveries.length) + throw new Error( + authorizedOutputs.length === 0 + ? 'Payment Completion requires one Receipt per Delivery' + : 'Payment Completion requires one settlement proof per Delivery' + ) + const expected = new Map( + payment.deliveries.map(delivery => [toHex(delivery.demandId), delivery] as const) + ) + const seen = new Set() + for (const receipt of receipts) { + await validatePaymentReceipt(receipt) + equal(receipt.body.requestId, payment.plan.requestId, 'Receipt Request ID') + const demandId = memberBytes(receipt.body, 'demandId', 32) + const demandIdHex = toHex(demandId) + const delivery = expected.get(demandIdHex) + if (delivery === undefined || seen.has(demandIdHex)) + throw new Error('Payment Completion has an unexpected or repeated Receipt') + equal(receipt.body.payee, delivery.payee, 'Receipt Payee') + seen.add(demandIdHex) + } + for (const bundle of authorizedOutputs) { + const demandId = memberBytes(bundle.authorization.body, 'demandId', 32) + const demandIdHex = toHex(demandId) + const delivery = expected.get(demandIdHex) + if (delivery === undefined || seen.has(demandIdHex)) + throw new Error('Payment Completion has an unexpected or repeated authorized output') + const demand = await demandById(payment.plan.demands, demandId) + if (demand.body.settlementProfile !== LCH_SETTLEMENT_PROFILES.authorizedOutput) + throw new Error('Payment Demand does not permit authorized-output settlement') + await validateAuthorizedOutputEvidence( + bundle, + demand, + payment.atomicBeef, + new PublicBRC77Verifier(), + { allowInsecureLocalOrigins: this.allowInsecureLocalOrigins } + ) + seen.add(demandIdHex) + } + const completion: PaymentCompletion = { + request: payment.plan.request, + quote: payment.plan.quote, + atomicBeef: payment.atomicBeef, + receipts: [...receipts], + authorizedOutputs: [...authorizedOutputs] + } + const license = await this.transport.complete(payment.plan.endpoint, completion) + await verifySignedObject('license', license, new PublicBRC77Verifier(), payment.plan.issuer) + equal(license.body.requestId, payment.plan.requestId, 'License Request ID') + equal(license.body.subject, this.signer.identityKey, 'License subject') + return license + } + + recover(endpoint: string, requestId: Uint8Array): Promise { + return this.transport.recover(endpoint, requestId) + } + + async collectAuthorizedOutputEvidence( + payment: LCHFundedMultipay, + item: LCHMultipayDelivery + ): Promise { + const demand = await demandById(payment.plan.demands, item.demandId) + const authorization = payment.plan.authorizations.find( + candidate => toHex(memberBytes(candidate.body, 'demandId', 32)) === toHex(item.demandId) + ) + if (authorization === undefined) + throw new Error('Payment plan has no Authorization for this Delivery') + await validatePaymentAuthorization( + authorization, + demand, + undefined, + new PublicBRC77Verifier(), + { allowInsecureLocalOrigins: this.allowInsecureLocalOrigins } + ) + const deliveryAcknowledgement = await this.transport.storeDelivery( + memberString(authorization.body, 'deliveryEndpoint'), + authorization, + item.delivery + ) + await validateDeliveryAcknowledgement( + deliveryAcknowledgement, + authorization, + item.delivery, + await objectId('payment-authorization', authorization.body), + await objectId('payment-delivery', item.delivery.body), + new PublicBRC77Verifier(), + { allowInsecureLocalOrigins: this.allowInsecureLocalOrigins } + ) + const transactionEvidence = await this.transport.attestTransaction( + memberString(authorization.body, 'evidenceEndpoint'), + authorization, + payment.atomicBeef + ) + await validateTransactionEvidence( + transactionEvidence, + authorization, + await objectId('payment-authorization', authorization.body), + Transaction.fromAtomicBEEF(payment.atomicBeef as AtomicBEEF), + new PublicBRC77Verifier() + ) + const bundle = { + authorization, + delivery: item.delivery, + transactionEvidence, + deliveryAcknowledgement + } + await validateAuthorizedOutputEvidence( + bundle, + demand, + payment.atomicBeef, + new PublicBRC77Verifier(), + { allowInsecureLocalOrigins: this.allowInsecureLocalOrigins } + ) + return bundle + } + + async settleDelivery( + payment: LCHFundedMultipay, + item: LCHMultipayDelivery + ): Promise { + try { + return { type: 'receipt', receipt: await this.deliver(payment, item) } + } catch (error) { + const demand = await demandById(payment.plan.demands, item.demandId) + if (demand.body.settlementProfile !== LCH_SETTLEMENT_PROFILES.authorizedOutput) throw error + return { + type: 'authorized-output', + evidence: await this.collectAuthorizedOutputEvidence(payment, item) + } + } + } + + private async obtainReadiness(demands: readonly SignedObject[]): Promise { + const readiness: SignedObject[] = [] + for (const demand of demands) { + const ready = await this.transport.preflightDemand( + memberString(demand.body, 'endpoint'), + demand + ) + await validatePaymentReadiness(ready, demand, this.now(), new PublicBRC77Verifier(), { + allowInsecureLocalOrigins: this.allowInsecureLocalOrigins + }) + readiness.push(ready) + } + return readiness + } + + private async obtainAuthorizations(demands: readonly SignedObject[]): Promise { + const authorizations: SignedObject[] = [] + for (const demand of demands) { + if (demand.body.settlementProfile !== LCH_SETTLEMENT_PROFILES.authorizedOutput) continue + const authorization = await this.transport.authorizePayment( + memberString(demand.body, 'endpoint'), + demand + ) + await validatePaymentAuthorization( + authorization, + demand, + this.now(), + new PublicBRC77Verifier(), + { allowInsecureLocalOrigins: this.allowInsecureLocalOrigins } + ) + authorizations.push(authorization) + } + return authorizations + } + + private async validatePlanAuthorizations( + demands: readonly SignedObject[], + authorizations: readonly SignedObject[], + now: bigint + ): Promise> { + const available = new Map( + authorizations.map(item => [toHex(memberBytes(item.body, 'demandId', 32)), item] as const) + ) + if (available.size !== authorizations.length) + throw new Error('Payment plan has a repeated Authorization') + for (const demand of demands) { + const demandId = await objectId('payment-demand', demand.body) + const demandIdHex = toHex(demandId) + const authorization = available.get(demandIdHex) + if (demand.body.settlementProfile === LCH_SETTLEMENT_PROFILES.authorizedOutput) { + if (authorization === undefined) + throw new Error('Payment plan is missing a required Payment Authorization') + await validatePaymentAuthorization(authorization, demand, now, new PublicBRC77Verifier(), { + allowInsecureLocalOrigins: this.allowInsecureLocalOrigins + }) + } else if (authorization !== undefined) { + throw new Error('Payment plan has an Authorization for a receipt-only Demand') + } + } + return available + } + + private async validatePlanReadiness( + demands: readonly SignedObject[], + readiness: readonly SignedObject[], + now: bigint + ): Promise { + if (readiness.length !== demands.length) + throw new Error('Payment plan requires one current Readiness per Demand') + const available = new Map( + readiness.map(item => [toHex(memberBytes(item.body, 'demandId', 32)), item] as const) + ) + if (available.size !== readiness.length) + throw new Error('Payment plan has a repeated Readiness') + for (const demand of demands) { + const demandId = await objectId('payment-demand', demand.body) + const ready = available.get(toHex(demandId)) + if (ready === undefined) throw new Error('Payment plan is missing a Demand Readiness') + await validatePaymentReadiness(ready, demand, now, new PublicBRC77Verifier(), { + allowInsecureLocalOrigins: this.allowInsecureLocalOrigins + }) + } + } +} + +async function demandById( + demands: readonly SignedObject[], + expected: Uint8Array +): Promise { + for (const demand of demands) { + if (toHex(await objectId('payment-demand', demand.body)) === toHex(expected)) return demand + } + throw new Error('Payment Delivery refers to an unknown Demand') +} + +function signedArray(value: LCHValue | undefined): SignedObject[] { + if (!Array.isArray(value) || value.length < 2) + throw new Error('Multilateral Quote requires at least two Payment Demands') + return value.map(item => { + if ( + item === null || + typeof item !== 'object' || + Array.isArray(item) || + item instanceof Uint8Array || + item.body === null || + typeof item.body !== 'object' || + Array.isArray(item.body) || + item.body instanceof Uint8Array || + !Array.isArray(item.signatures) || + !item.signatures.every(signature => signature instanceof Uint8Array) + ) + throw new Error('Quote Payment Demand is invalid') + return { + body: item.body as Record, + signatures: item.signatures as Uint8Array[] + } + }) +} + +function memberBytes(body: Record, key: string, length: number): Uint8Array { + const value = body[key] + if (!(value instanceof Uint8Array) || value.length !== length) + throw new Error(`${key} is invalid`) + return value +} + +function memberBytesAny(body: Record, key: string): Uint8Array { + const value = body[key] + if (!(value instanceof Uint8Array) || value.length === 0) throw new Error(`${key} is invalid`) + return value +} + +function memberString(body: Record, key: string): string { + const value = body[key] + if (typeof value !== 'string' || value.length === 0) throw new Error(`${key} is invalid`) + return value +} + +function uint(value: LCHValue | undefined, name: string): bigint { + if (typeof value !== 'bigint' && !(typeof value === 'number' && Number.isSafeInteger(value))) + throw new Error(`${name} is invalid`) + const result = BigInt(value) + if (result < 0n) throw new Error(`${name} is negative`) + return result +} + +function equal(value: LCHValue | undefined, expected: Uint8Array, name: string): void { + if (!(value instanceof Uint8Array) || toHex(value) !== toHex(expected)) + throw new Error(`${name} does not match`) +} diff --git a/packages/content/lch/src/objects.ts b/packages/content/lch/src/objects.ts new file mode 100644 index 000000000..de12bf27a --- /dev/null +++ b/packages/content/lch/src/objects.ts @@ -0,0 +1,40 @@ +import { lchAssert } from './errors.js' +import { objectPreimage, toHex } from './hash.js' +import { brc77SignerIdentity } from './signatures.js' +import type { + LCHObjectType, + LCHSigner, + LCHSignatureVerifier, + LCHValue, + SignedObject +} from './types.js' + +export async function signObject>( + type: LCHObjectType, + body: T, + signer: LCHSigner +): Promise> { + return { body, signatures: [await signer.sign(objectPreimage(type, body))] } +} + +export async function verifySignedObject( + type: LCHObjectType, + object: SignedObject, + verifier: LCHSignatureVerifier, + requiredSigner?: Uint8Array +): Promise { + lchAssert(object.signatures.length > 0, 'ERR_LCH_SIGNATURE', 'Signed object has no signatures') + const preimage = objectPreimage(type, object.body) + let matched = false + for (const signature of object.signatures) { + if ( + requiredSigner !== undefined && + toHex(brc77SignerIdentity(signature)) !== toHex(requiredSigner) + ) + continue + if (await verifier.verify(preimage, signature)) matched = true + } + lchAssert(matched, 'ERR_LCH_SIGNATURE', 'No valid signature from the required signer') +} + +export { objectId, objectIri } from './hash.js' diff --git a/packages/content/lch/src/payment.ts b/packages/content/lch/src/payment.ts new file mode 100644 index 000000000..4807b9436 --- /dev/null +++ b/packages/content/lch/src/payment.ts @@ -0,0 +1,116 @@ +import { lchAssert } from './errors.js' +import type { PaymentDemand, PaymentOutput } from './types.js' + +const MAX_SATOSHIS = 2_100_000_000_000_000n + +function checkedUint( + value: number | bigint, + code: 'ERR_LCH_PAYMENT' | 'ERR_LCH_QUOTE', + name: string +): bigint { + lchAssert( + typeof value === 'bigint' || Number.isSafeInteger(value), + code, + `${name} must be an exact integer` + ) + const result = BigInt(value) + lchAssert(result >= 0n, code, `${name} must be unsigned`) + return result +} + +export function checkedSatoshis(value: number | bigint): bigint { + const amount = checkedUint(value, 'ERR_LCH_PAYMENT', 'Satoshi amount') + lchAssert( + amount >= 0n && amount <= MAX_SATOSHIS, + 'ERR_LCH_PAYMENT', + 'Satoshi amount is out of range' + ) + return amount +} + +export function fixedTotal(requirements: ReadonlyArray<{ satoshis: number | bigint }>): bigint { + return requirements.reduce((total, requirement) => { + const next = total + checkedSatoshis(requirement.satoshis) + return checkedSatoshis(next) + }, 0n) +} + +export function unitAmount( + quantity: number | bigint, + unitSize: number | bigint, + minimumUnits: number | bigint, + pricePerUnit: number | bigint, + maximumUnits?: number | bigint +): bigint { + const selected = checkedUint(quantity, 'ERR_LCH_QUOTE', 'Quantity') + const size = checkedUint(unitSize, 'ERR_LCH_QUOTE', 'Unit size') + const minimum = checkedUint(minimumUnits, 'ERR_LCH_QUOTE', 'Minimum units') + lchAssert(size > 0n, 'ERR_LCH_QUOTE', 'Unit size must be positive') + const roundedUnits = (selected + size - 1n) / size + const units = minimum > roundedUnits ? minimum : roundedUnits + if (maximumUnits !== undefined) + lchAssert( + units <= checkedUint(maximumUnits, 'ERR_LCH_QUOTE', 'Maximum units'), + 'ERR_LCH_QUOTE', + 'Maximum units exceeded' + ) + return checkedSatoshis(units * checkedSatoshis(pricePerUnit)) +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return left.length === right.length && left.every((byte, index) => byte === right[index]) +} + +export function matchFinalizedOutputs( + demands: readonly PaymentDemand[], + outputs: readonly PaymentOutput[] +): Map { + lchAssert(demands.length > 0, 'ERR_LCH_PAYMENT', 'No payment Demands were supplied') + const demandIds = demands.map(demand => { + lchAssert(demand.demandId.length === 32, 'ERR_LCH_PAYMENT', 'Demand ID must contain 32 bytes') + return Array.from(demand.demandId, byte => byte.toString(16).padStart(2, '0')).join('') + }) + lchAssert( + new Set(demandIds).size === demandIds.length, + 'ERR_LCH_PAYMENT', + 'Demand IDs must be unique' + ) + const used = new Set() + const matches = new Map() + for (const demand of demands) { + const candidates = outputs + .map((output, index) => ({ output, index })) + .filter( + ({ output, index }) => + !used.has(index) && + output.satoshis === demand.satoshis && + bytesEqual(output.lockingScript, demand.lockingScript) + ) + lchAssert( + candidates.length === 1, + 'ERR_LCH_PAYMENT', + 'Demand output is missing or ambiguous after finalization' + ) + const index = candidates[0].output.outputIndex ?? candidates[0].index + lchAssert( + Number.isSafeInteger(index) && index >= 0, + 'ERR_LCH_PAYMENT', + 'Finalized output index is invalid' + ) + used.add(candidates[0].index) + matches.set(demandIds[matches.size], index) + } + return matches +} + +export function recoveryUntil( + expiresAt: number | bigint, + recoveryPeriodSeconds: number | bigint +): bigint { + const expires = checkedUint(expiresAt, 'ERR_LCH_QUOTE', 'Quote expiry') + const period = checkedUint(recoveryPeriodSeconds, 'ERR_LCH_QUOTE', 'Recovery period') + lchAssert(period >= 86_400n, 'ERR_LCH_QUOTE', 'Recovery period must be at least one day') + const result = expires + period + lchAssert(result <= 0xffffffffffffffffn, 'ERR_LCH_QUOTE', 'Recovery deadline overflows uint64') + return result +} diff --git a/packages/content/lch/src/paymentReceiver.ts b/packages/content/lch/src/paymentReceiver.ts new file mode 100644 index 000000000..cb60e1f59 --- /dev/null +++ b/packages/content/lch/src/paymentReceiver.ts @@ -0,0 +1,233 @@ +import { P2PKH, PublicKey, Transaction, type AtomicBEEF, type WalletInterface } from '@bsv/sdk' +import { LCHPayee, validatePaymentDelivery, validatePaymentDemand } from './acquisition.js' +import { lchAssert } from './errors.js' +import { fromHex, toBase64Url, toHex } from './hash.js' +import { BRC29_PAYMENT_PROTOCOL } from './walletPayment.js' +import type { LCHSignatureVerifier, LCHSigner, SignedObject } from './types.js' + +export type PaymentClaimStatus = 'new' | 'same' | 'conflict' + +export interface PaymentLedgerEntry { + fingerprint: string + receipt?: SignedObject +} + +export interface PaymentLedger { + claim(demandId: string, fingerprint: string): Promise + get(demandId: string): Promise + complete(demandId: string, fingerprint: string, receipt: SignedObject): Promise +} + +export class MemoryPaymentLedger implements PaymentLedger { + private readonly entries = new Map() + + constructor(private readonly maximumEntries = 100_000) { + lchAssert( + Number.isSafeInteger(maximumEntries) && maximumEntries > 0, + 'ERR_LCH_PAYMENT', + 'Payment ledger capacity is invalid' + ) + } + + async claim(demandId: string, fingerprint: string): Promise { + const existing = this.entries.get(demandId) + if (existing !== undefined) return existing.fingerprint === fingerprint ? 'same' : 'conflict' + lchAssert( + this.entries.size < this.maximumEntries, + 'ERR_LCH_PAYMENT', + 'Payment ledger capacity is exhausted' + ) + this.entries.set(demandId, { fingerprint }) + return 'new' + } + + async get(demandId: string): Promise { + return this.entries.get(demandId) + } + + async complete(demandId: string, fingerprint: string, receipt: SignedObject): Promise { + const existing = this.entries.get(demandId) + lchAssert( + existing?.fingerprint === fingerprint, + 'ERR_LCH_PAYMENT', + 'Payment ledger claim changed before completion' + ) + this.entries.set(demandId, { fingerprint, receipt }) + } +} + +export interface WalletPaymentReceiverOptions { + wallet: Pick + signer: LCHSigner + ledger?: PaymentLedger + verifier?: LCHSignatureVerifier + now?: () => bigint + allowInsecureLocalOrigins?: readonly string[] +} + +export class WalletPaymentReceiver { + private readonly payee: LCHPayee + private readonly ledger: PaymentLedger + private readonly now: () => bigint + + constructor(private readonly options: WalletPaymentReceiverOptions) { + this.payee = new LCHPayee(options.signer) + this.ledger = options.ledger ?? new MemoryPaymentLedger() + this.now = options.now ?? (() => BigInt(Math.floor(Date.now() / 1000))) + } + + async preflight(demand: SignedObject): Promise { + await validatePaymentDemand(demand, this.options.verifier, { + allowInsecureLocalOrigins: this.options.allowInsecureLocalOrigins + }) + const payee = bytes(demand.body.payee, 33, 'Demand payee') + lchAssert( + toHex(payee) === toHex(this.options.signer.identityKey), + 'ERR_LCH_AUTHORITY', + 'Payment Demand belongs to another payee' + ) + lchAssert( + this.now() < integer(demand.body.expiresAt, 'Demand expiry'), + 'ERR_LCH_QUOTE', + 'Payment Demand has expired' + ) + } + + async receive(demand: SignedObject, delivery: SignedObject): Promise { + const demandId = await validatePaymentDemand(demand, this.options.verifier, { + allowInsecureLocalOrigins: this.options.allowInsecureLocalOrigins + }) + await validatePaymentDelivery(delivery, this.options.verifier) + const payee = bytes(demand.body.payee, 33, 'Demand payee') + lchAssert( + toHex(payee) === toHex(this.options.signer.identityKey), + 'ERR_LCH_AUTHORITY', + 'Payment Demand belongs to another payee' + ) + equal(delivery.body.demandId, demandId, 'Payment Delivery Demand ID') + equal(delivery.body.requestId, demand.body.requestId, 'Payment Delivery Request ID') + equal(delivery.body.derivationPrefix, demand.body.derivationPrefix, 'Derivation prefix') + lchAssert( + this.now() < integer(demand.body.recoveryUntil, 'Demand recovery deadline'), + 'ERR_LCH_PAYMENT', + 'Payment recovery deadline has passed' + ) + + const buyer = bytes(delivery.body.buyer, 33, 'Buyer identity') + equal(buyer, demand.body.buyer, 'Payment Delivery buyer identity') + const atomicBeef = bytes(delivery.body.atomicBeef, undefined, 'Atomic BEEF') + const outputIndex = index(delivery.body.outputIndex) + const prefix = bytes(delivery.body.derivationPrefix, 32, 'Derivation prefix') + const suffix = bytes(delivery.body.derivationSuffix, 32, 'Derivation suffix') + const transaction = parseAtomicBeef(atomicBeef) + const output = transaction.outputs[outputIndex] + lchAssert(output?.satoshis !== undefined, 'ERR_LCH_PAYMENT', 'Payment output is absent') + const satoshis = integer(demand.body.satoshis, 'Demand amount') + lchAssert( + BigInt(output.satoshis) === satoshis, + 'ERR_LCH_PAYMENT', + 'Payment output amount does not match the Demand' + ) + const keyID = `${toBase64Url(prefix)} ${toBase64Url(suffix)}` + const { publicKey } = await this.options.wallet.getPublicKey({ + protocolID: [...BRC29_PAYMENT_PROTOCOL], + keyID, + counterparty: toHex(buyer), + forSelf: true + }) + const expected = new P2PKH().lock(PublicKey.fromString(publicKey).toAddress()).toUint8Array() + lchAssert( + toHex(output.lockingScript.toUint8Array()) === toHex(expected), + 'ERR_LCH_PAYMENT', + 'Payment output locking script does not match the Demand remittance' + ) + + const txidHex = transaction.id('hex') + const fingerprint = `${txidHex}:${outputIndex}:${toHex(buyer)}` + const demandIdHex = toHex(demandId) + const claim = await this.ledger.claim(demandIdHex, fingerprint) + lchAssert(claim !== 'conflict', 'ERR_LCH_PAYMENT', 'Payment Demand was reused') + const existing = await this.ledger.get(demandIdHex) + if (existing?.receipt !== undefined) return existing.receipt + + const result = (await this.options.wallet.internalizeAction({ + tx: Array.from(atomicBeef), + outputs: [ + { + outputIndex, + protocol: 'wallet payment', + paymentRemittance: { + derivationPrefix: toBase64Url(prefix), + derivationSuffix: toBase64Url(suffix), + senderIdentityKey: toHex(buyer) + } + } + ], + description: `LCH payment ${demandIdHex}` + })) as { accepted?: boolean; isMerge?: boolean } + lchAssert( + result.accepted === true || (claim === 'same' && result.isMerge === true), + 'ERR_LCH_PAYMENT', + 'Receiving wallet did not accept the Payment Demand output' + ) + const receipt = await this.payee.createReceipt({ + demandId, + requestId: bytes(demand.body.requestId, 32, 'Request ID'), + txid: fromHex(txidHex), + outputIndex, + satoshis, + receivedAt: this.now() + }) + await this.ledger.complete(demandIdHex, fingerprint, receipt) + return receipt + } +} + +function parseAtomicBeef(bytes: Uint8Array): Transaction { + try { + return Transaction.fromAtomicBEEF(bytes as AtomicBEEF) + } catch (error) { + throw new Error('Atomic BEEF could not be parsed', { cause: error }) + } +} + +function bytes(value: unknown, length: number | undefined, name: string): Uint8Array { + lchAssert( + value instanceof Uint8Array && + value.length > 0 && + (length === undefined || value.length === length), + 'ERR_LCH_PAYMENT', + `${name} is invalid` + ) + return value +} + +function integer(value: unknown, name: string): bigint { + lchAssert( + typeof value === 'bigint' || (typeof value === 'number' && Number.isSafeInteger(value)), + 'ERR_LCH_PAYMENT', + `${name} is not an exact integer` + ) + const result = BigInt(value) + lchAssert(result >= 0n, 'ERR_LCH_PAYMENT', `${name} is negative`) + return result +} + +function index(value: unknown): number { + lchAssert( + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0, + 'ERR_LCH_PAYMENT', + 'Payment output index is invalid' + ) + return value +} + +function equal(value: unknown, expected: unknown, name: string): void { + lchAssert( + value instanceof Uint8Array && + expected instanceof Uint8Array && + toHex(value) === toHex(expected), + 'ERR_LCH_PAYMENT', + `${name} does not match` + ) +} diff --git a/packages/content/lch/src/policy.ts b/packages/content/lch/src/policy.ts new file mode 100644 index 000000000..d58cae061 --- /dev/null +++ b/packages/content/lch/src/policy.ts @@ -0,0 +1,137 @@ +import { LCH_IRI } from './constants.js' +import { LCHError, lchAssert } from './errors.js' +import { sha256, toHex } from './hash.js' + +export const ODRL_CONTEXT = 'http://www.w3.org/ns/odrl.jsonld' +export const LCH_ODRL_PROFILE = `${LCH_IRI}#odrl-profile` +export const LCH_ODRL_CONTEXT = { + lchv: `${LCH_IRI}#`, + render: 'lchv:render', + unwrap: 'lchv:unwrap', + train: 'lchv:train', + retainLch: 'lchv:retainLch', + presentTerms: 'lchv:presentTerms', + selection: { '@id': 'lchv:selection', '@type': '@id' }, + connectivity: { '@id': 'lchv:connectivity', '@type': '@id' }, + enforcementClass: { '@id': 'lchv:enforcementClass', '@type': '@id' }, + commercialPurpose: 'lchv:commercialPurpose', + wrapperRequired: 'lchv:wrapperRequired', + satoshi: 'lchv:satoshi' +} as const + +export interface PolicyReference { + mediaType: string + digest: Uint8Array + inline?: Uint8Array + locator?: string +} + +export interface PolicyEvaluation { + policy: Record + permissions: Array> + prohibitions: Array> + duties: Array> +} + +export async function parsePinnedPolicy( + reference: PolicyReference, + expectedType: 'Offer' | 'Agreement', + computedIri: string +): Promise { + lchAssert( + reference.mediaType === 'application/ld+json' && reference.inline !== undefined, + 'ERR_LCH_POLICY', + 'Core evaluator requires an inline JSON-LD policy' + ) + lchAssert( + reference.digest.length === 32 && + toHex(await sha256(reference.inline)) === toHex(reference.digest), + 'ERR_LCH_POLICY', + 'Policy digest mismatch' + ) + let value: unknown + try { + value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(reference.inline)) + } catch (error) { + throw new LCHError('ERR_LCH_POLICY', 'Policy is not valid UTF-8 JSON', { cause: error }) + } + lchAssert( + value !== null && typeof value === 'object' && !Array.isArray(value), + 'ERR_LCH_POLICY', + 'Policy must be a JSON object' + ) + const policy = value as Record + lchAssert( + policy['@type'] === expectedType, + 'ERR_LCH_POLICY', + `Policy must be an ODRL ${expectedType}` + ) + const self = expectedType === 'Offer' ? 'lch:offer:self' : 'lch:license:self' + lchAssert(policy.uid === self, 'ERR_LCH_POLICY', `Policy top-level uid must be ${self}`) + const contexts = Array.isArray(policy['@context']) ? policy['@context'] : [policy['@context']] + lchAssert( + contexts.includes(ODRL_CONTEXT) && + contexts.every(context => context === ODRL_CONTEXT || sameJson(context, LCH_ODRL_CONTEXT)) && + contexts.filter(context => context === ODRL_CONTEXT).length === 1 && + contexts.filter(context => sameJson(context, LCH_ODRL_CONTEXT)).length <= 1, + 'ERR_LCH_POLICY', + 'Policy names an absent, duplicate, or unsupported context' + ) + lchAssert(policy.profile === LCH_ODRL_PROFILE, 'ERR_LCH_POLICY', 'LCH ODRL profile is absent') + lchAssert( + policy.conflict === undefined || + policy.conflict === 'invalid' || + policy.conflict === 'odrl:invalid', + 'ERR_LCH_POLICY', + 'LCH Policies require the ODRL invalid conflict strategy' + ) + const evaluated = { ...policy, uid: computedIri } + const permissions = arrayOfObjects(policy.permission) + const prohibitions = arrayOfObjects(policy.prohibition) + const duties = permissions.flatMap(permission => arrayOfObjects(permission.duty)) + return { policy: evaluated, permissions, prohibitions, duties } +} + +function sameJson(left: unknown, right: unknown): boolean { + if (left === right) return true + if ( + left === null || + right === null || + typeof left !== 'object' || + typeof right !== 'object' || + Array.isArray(left) || + Array.isArray(right) + ) { + return false + } + const leftEntries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b)) + const rightEntries = Object.entries(right).sort(([a], [b]) => a.localeCompare(b)) + return ( + leftEntries.length === rightEntries.length && + leftEntries.every( + ([key, value], index) => + key === rightEntries[index]?.[0] && sameJson(value, rightEntries[index]?.[1]) + ) + ) +} + +function arrayOfObjects(value: unknown): Array> { + let values: unknown[] + if (value === undefined) values = [] + else if (Array.isArray(value)) values = value + else values = [value] + lchAssert( + values.every(item => item !== null && typeof item === 'object' && !Array.isArray(item)), + 'ERR_LCH_POLICY', + 'Policy rule must be an object' + ) + return values as Array> +} + +export function permits(evaluation: PolicyEvaluation, action: string, target: string): boolean { + const prohibited = evaluation.prohibitions.some( + rule => rule.action === action && rule.target === target + ) + if (prohibited) return false + return evaluation.permissions.some(rule => rule.action === action && rule.target === target) +} diff --git a/packages/content/lch/src/profiles.ts b/packages/content/lch/src/profiles.ts new file mode 100644 index 000000000..f543651bc --- /dev/null +++ b/packages/content/lch/src/profiles.ts @@ -0,0 +1,68 @@ +import { LCH_MECHANISMS, LCH_PROFILES } from './constants.js' +import { lchAssert } from './errors.js' + +export interface LCHCapabilitySet { + usageProfiles: ReadonlySet + paymentMechanisms: ReadonlySet + keyDeliveryMechanisms: ReadonlySet + encryptionMechanisms: ReadonlySet + enforcementClasses: ReadonlySet + compositionMappings: ReadonlySet +} + +export const CORE_CAPABILITIES: LCHCapabilitySet = { + usageProfiles: new Set(Object.values(LCH_PROFILES)), + paymentMechanisms: new Set([ + LCH_MECHANISMS.brc105Single, + LCH_MECHANISMS.brc105Multipay, + LCH_MECHANISMS.brc121Single + ]), + keyDeliveryMechanisms: new Set([LCH_MECHANISMS.brc78Key, LCH_MECHANISMS.rawKey]), + encryptionMechanisms: new Set([LCH_MECHANISMS.encryption]), + enforcementClasses: new Set([ + 'https://bsv.brc.dev/apps/0170#advisory', + 'https://bsv.brc.dev/apps/0170#conformingApplication', + 'https://bsv.brc.dev/apps/0170#protectedModule' + ]), + compositionMappings: new Set([LCH_MECHANISMS.wholePlacement]) +} + +export interface OfferedMechanisms { + usageProfile: string + payment: string + keyDelivery: string + encryption: string + enforcement: string + critical?: readonly string[] +} + +export function supportsProfile( + offer: OfferedMechanisms, + capabilities = CORE_CAPABILITIES +): boolean { + return ( + capabilities.usageProfiles.has(offer.usageProfile) && + capabilities.paymentMechanisms.has(offer.payment) && + capabilities.keyDeliveryMechanisms.has(offer.keyDelivery) && + capabilities.encryptionMechanisms.has(offer.encryption) && + capabilities.enforcementClasses.has(offer.enforcement) && + (offer.critical ?? []).every(identifier => + [ + ...capabilities.usageProfiles, + ...capabilities.paymentMechanisms, + ...capabilities.keyDeliveryMechanisms, + ...capabilities.encryptionMechanisms, + ...capabilities.enforcementClasses, + ...capabilities.compositionMappings + ].includes(identifier) + ) + ) +} + +export function requireProfile(offer: OfferedMechanisms, capabilities = CORE_CAPABILITIES): void { + lchAssert( + supportsProfile(offer, capabilities), + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'No fully supported LCH acquisition profile' + ) +} diff --git a/packages/content/lch/src/selection.ts b/packages/content/lch/src/selection.ts new file mode 100644 index 000000000..34866d4d3 --- /dev/null +++ b/packages/content/lch/src/selection.ts @@ -0,0 +1,101 @@ +import { lchAssert } from './errors.js' +import type { RangeTuple, Selection } from './types.js' + +function asBigInt(value: number | bigint): bigint { + lchAssert( + typeof value === 'bigint' || Number.isSafeInteger(value), + 'ERR_LCH_SELECTION', + 'Selection bound is not an integer' + ) + return BigInt(value) +} + +function compareRangeStarts( + left: readonly [bigint, bigint], + right: readonly [bigint, bigint] +): number { + if (left[0] < right[0]) return -1 + if (left[0] > right[0]) return 1 + return 0 +} + +export function normalizeRanges(ranges: readonly RangeTuple[]): RangeTuple[] { + lchAssert(ranges.length > 0, 'ERR_LCH_SELECTION', 'Selection ranges cannot be empty') + const sorted = ranges + .map(([start, end]) => [asBigInt(start), asBigInt(end)] as const) + .sort(compareRangeStarts) + const normalized: Array<[bigint, bigint]> = [] + for (const [start, end] of sorted) { + lchAssert( + start >= 0n && start < end, + 'ERR_LCH_SELECTION', + 'Selection range must be nonempty and unsigned' + ) + const previous = normalized.at(-1) + if (previous !== undefined && start <= previous[1]) + previous[1] = previous[1] > end ? previous[1] : end + else normalized.push([start, end]) + } + return normalized +} + +export function normalizeSelection(selection: Selection): Selection { + if (selection.type === 'all') return selection + if (selection.type === 'media-fragment') { + lchAssert( + selection.value.length > 0 && selection.value.length <= 4096, + 'ERR_LCH_SELECTION', + 'Media fragment is empty or too long' + ) + return selection + } + return { type: selection.type, ranges: normalizeRanges(selection.ranges) } +} + +export function validateNormalizedSelection(selection: Selection): void { + const normalized = normalizeSelection(selection) + if ( + selection.type === 'all' || + selection.type === 'media-fragment' || + normalized.type === 'all' || + normalized.type === 'media-fragment' + ) { + return + } + lchAssert( + selection.ranges.length === normalized.ranges.length && + selection.ranges.every( + ([start, end], index) => + BigInt(start) === BigInt(normalized.ranges[index][0]) && + BigInt(end) === BigInt(normalized.ranges[index][1]) + ), + 'ERR_LCH_SELECTION', + 'Selection ranges are not normalized' + ) +} + +export function selectionsIntersect(left: Selection, right: Selection): boolean { + if (left.type === 'all' || right.type === 'all') return true + if (left.type !== right.type || left.type === 'media-fragment' || right.type === 'media-fragment') + return false + const leftRanges = normalizeRanges(left.ranges) + const rightRanges = normalizeRanges(right.ranges) + return leftRanges.some(([leftStart, leftEnd]) => + rightRanges.some( + ([rightStart, rightEnd]) => + asBigInt(leftStart) < asBigInt(rightEnd) && asBigInt(rightStart) < asBigInt(leftEnd) + ) + ) +} + +export function selectionQuantity(selection: Selection): bigint { + lchAssert( + selection.type !== 'all' && selection.type !== 'media-fragment', + 'ERR_LCH_SELECTION', + 'Selection has no integer quantity' + ) + return normalizeRanges(selection.ranges).reduce( + (total, [start, end]) => total + asBigInt(end) - asBigInt(start), + 0n + ) +} diff --git a/packages/content/lch/src/settlement.ts b/packages/content/lch/src/settlement.ts new file mode 100644 index 000000000..3ec04422f --- /dev/null +++ b/packages/content/lch/src/settlement.ts @@ -0,0 +1,665 @@ +import { P2PKH, PublicKey, Transaction, type AtomicBEEF, type WalletInterface } from '@bsv/sdk' +import { LCH_SETTLEMENT_PROFILES, LCH_TRANSACTION_EVIDENCE_POLICIES } from './constants.js' +import { + validatePaymentDelivery, + validatePaymentDemand, + type AcquisitionValidationOptions +} from './acquisition.js' +import { lchAssert } from './errors.js' +import { fromHex, objectId, toBase64Url, toHex } from './hash.js' +import { signObject, verifySignedObject } from './objects.js' +import { PublicBRC77Verifier } from './signatures.js' +import { BRC29_PAYMENT_PROTOCOL } from './walletPayment.js' +import type { + LCHSignatureVerifier, + LCHSigner, + LCHTransactionState, + LCHUint, + LCHValue, + SignedObject +} from './types.js' + +const MAX_UINT64 = 0xffffffffffffffffn + +export interface AuthorizedOutputPolicy { + evidenceProvider: Uint8Array + evidenceEndpoint: string + evidencePolicy?: string + minimumTransactionState?: Extract + deliveryProvider: Uint8Array + deliveryEndpoint: string + retrievalEndpoint: string + allowInsecureLocalEndpoint?: boolean +} + +export interface PaymentAuthorizationStore { + get(demandId: string): Promise + putIfAbsent(demandId: string, authorization: SignedObject): Promise +} + +export class MemoryPaymentAuthorizationStore implements PaymentAuthorizationStore { + private readonly authorizations = new Map() + + constructor(private readonly maximumEntries = 100_000) { + lchAssert( + Number.isSafeInteger(maximumEntries) && maximumEntries > 0, + 'ERR_LCH_PAYMENT', + 'Payment Authorization store capacity is invalid' + ) + } + + async get(demandId: string): Promise { + return this.authorizations.get(demandId) + } + + async putIfAbsent(demandId: string, authorization: SignedObject): Promise { + const existing = this.authorizations.get(demandId) + if (existing !== undefined) return existing + lchAssert( + this.authorizations.size < this.maximumEntries, + 'ERR_LCH_PAYMENT', + 'Payment Authorization store capacity is exhausted' + ) + this.authorizations.set(demandId, authorization) + return authorization + } +} + +export interface WalletAuthorizedOutputPayeeOptions { + wallet: Pick + signer: LCHSigner + store?: PaymentAuthorizationStore + verifier?: LCHSignatureVerifier + now?: () => bigint + random?: (length: number) => Uint8Array + allowInsecureLocalOrigins?: readonly string[] +} + +/** Creates idempotent, Payee-signed destinations for offline-capable settlement. */ +export class WalletAuthorizedOutputPayee { + private readonly store: PaymentAuthorizationStore + private readonly now: () => bigint + private readonly random: (length: number) => Uint8Array + + constructor(private readonly options: WalletAuthorizedOutputPayeeOptions) { + this.store = options.store ?? new MemoryPaymentAuthorizationStore() + this.now = options.now ?? (() => BigInt(Math.floor(Date.now() / 1000))) + this.random = options.random ?? secureRandom + } + + async authorize(demand: SignedObject, policy: AuthorizedOutputPolicy): Promise { + const demandId = await validatePaymentDemand(demand, this.options.verifier, { + allowInsecureLocalOrigins: this.options.allowInsecureLocalOrigins + }) + const demandIdHex = toHex(demandId) + const existing = await this.store.get(demandIdHex) + if (existing !== undefined) return existing + lchAssert( + demand.body.settlementProfile === LCH_SETTLEMENT_PROFILES.authorizedOutput, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Payment Demand does not permit authorized-output settlement' + ) + const payee = memberBytes(demand.body, 'payee', 33, 'Demand Payee') + equal(payee, this.options.signer.identityKey, 'Payment Authorization Payee') + bytes(policy.evidenceProvider, 33, 'Evidence provider') + bytes(policy.deliveryProvider, 33, 'Delivery provider') + endpoint(policy.evidenceEndpoint, policy.allowInsecureLocalEndpoint) + endpoint(policy.deliveryEndpoint, policy.allowInsecureLocalEndpoint) + endpoint(policy.retrievalEndpoint, policy.allowInsecureLocalEndpoint) + const evidencePolicy = + policy.evidencePolicy ?? LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance + lchAssert( + evidencePolicy === LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Transaction evidence policy is unsupported' + ) + const minimumTransactionState = policy.minimumTransactionState ?? 'accepted' + const authorizedAt = this.now() + const authorizedUntil = uint(demand.body.expiresAt, 'Demand expiry') + lchAssert( + authorizedAt < authorizedUntil, + 'ERR_LCH_PAYMENT', + 'Payment Demand expired before authorization' + ) + const derivationSuffix = this.random(32) + bytes(derivationSuffix, 32, 'Derivation suffix') + const derivationPrefix = memberBytes(demand.body, 'derivationPrefix', 32, 'Derivation prefix') + const buyer = memberBytes(demand.body, 'buyer', 33, 'Demand buyer') + const keyID = `${toBase64Url(derivationPrefix)} ${toBase64Url(derivationSuffix)}` + const { publicKey } = await this.options.wallet.getPublicKey({ + protocolID: [...BRC29_PAYMENT_PROTOCOL], + keyID, + counterparty: toHex(buyer), + forSelf: true + }) + const lockingScript = new P2PKH() + .lock(PublicKey.fromString(publicKey).toAddress()) + .toUint8Array() + const authorization = await signObject( + 'payment-authorization', + { + version: 1, + settlementProfile: LCH_SETTLEMENT_PROFILES.authorizedOutput, + demandId, + requestId: memberBytes(demand.body, 'requestId', 32, 'Request ID'), + payee, + buyer, + satoshis: demand.body.satoshis!, + derivationPrefix, + derivationSuffix, + lockingScript, + authorizedAt, + authorizedUntil, + recoveryUntil: demand.body.recoveryUntil!, + evidenceProvider: policy.evidenceProvider, + evidenceEndpoint: policy.evidenceEndpoint, + evidencePolicy, + minimumTransactionState, + deliveryProvider: policy.deliveryProvider, + deliveryEndpoint: policy.deliveryEndpoint, + retrievalEndpoint: policy.retrievalEndpoint + }, + this.options.signer + ) + const stored = await this.store.putIfAbsent(demandIdHex, authorization) + await validatePaymentAuthorization(stored, demand, authorizedAt, this.options.verifier, { + allowInsecureLocalOrigins: this.options.allowInsecureLocalOrigins + }) + return stored + } +} + +export interface TransactionEvidenceOptions { + authorizationId: Uint8Array + txid: Uint8Array + state: Extract + policy: string + observedAt: LCHUint +} + +export interface PaymentDeliveryAcknowledgementOptions { + authorizationId: Uint8Array + deliveryId: Uint8Array + demandId: Uint8Array + requestId: Uint8Array + payee: Uint8Array + storedAt: LCHUint + availableUntil: LCHUint + retrievalEndpoint: string + allowInsecureLocalEndpoint?: boolean +} + +/** Signs evidence emitted by a transaction processor or durable Delivery custodian. */ +export class LCHSettlementService { + constructor(private readonly signer: LCHSigner) {} + + createTransactionEvidence(options: TransactionEvidenceOptions): Promise { + bytes(options.authorizationId, 32, 'Payment Authorization ID') + bytes(options.txid, 32, 'Transaction ID') + lchAssert( + options.state === 'accepted', + 'ERR_LCH_PAYMENT', + 'Transaction evidence state is invalid' + ) + lchAssert( + options.policy === LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Transaction evidence policy is unsupported' + ) + uint(options.observedAt, 'Transaction evidence time') + return signObject( + 'transaction-evidence', + { + version: 1, + authorizationId: options.authorizationId, + txid: options.txid, + provider: this.signer.identityKey, + state: options.state, + policy: options.policy, + observedAt: options.observedAt + }, + this.signer + ) + } + + createDeliveryAcknowledgement( + options: PaymentDeliveryAcknowledgementOptions + ): Promise { + bytes(options.authorizationId, 32, 'Payment Authorization ID') + bytes(options.deliveryId, 32, 'Payment Delivery ID') + bytes(options.demandId, 32, 'Demand ID') + bytes(options.requestId, 32, 'Request ID') + bytes(options.payee, 33, 'Payee identity') + const storedAt = uint(options.storedAt, 'Delivery storage time') + const availableUntil = uint(options.availableUntil, 'Delivery availability deadline') + lchAssert( + storedAt < availableUntil, + 'ERR_LCH_DELIVERY', + 'Delivery availability window is empty' + ) + endpoint(options.retrievalEndpoint, options.allowInsecureLocalEndpoint) + return signObject( + 'payment-delivery-ack', + { + version: 1, + authorizationId: options.authorizationId, + deliveryId: options.deliveryId, + demandId: options.demandId, + requestId: options.requestId, + payee: options.payee, + provider: this.signer.identityKey, + storedAt: options.storedAt, + availableUntil: options.availableUntil, + retrievalEndpoint: options.retrievalEndpoint + }, + this.signer + ) + } +} + +export interface AuthorizedOutputEvidence { + authorization: SignedObject + delivery: SignedObject + transactionEvidence: SignedObject + deliveryAcknowledgement: SignedObject +} + +export interface TransactionEvidenceRequest { + authorization: SignedObject + atomicBeef: Uint8Array +} + +export interface PaymentDeliveryStoreRequest { + authorization: SignedObject + delivery: SignedObject +} + +export interface StoredPaymentDelivery { + authorization: SignedObject + delivery: SignedObject + deliveryAcknowledgement: SignedObject +} + +export async function validatePaymentDeliveryRetrieval( + request: SignedObject, + authorization: SignedObject, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier() +): Promise { + const payee = memberBytes(request.body, 'payee', 33, 'Retrieval Payee') + await verifySignedObject('payment-delivery-retrieval', request, verifier, payee) + equal( + memberBytes(request.body, 'authorizationId', 32, 'Payment Authorization ID'), + await objectId('payment-authorization', authorization.body), + 'Payment Authorization ID' + ) + equalMember(request.body, authorization.body, 'payee', 33, 'Payee') + uint(request.body.requestedAt, 'Delivery retrieval time') + memberBytes(request.body, 'nonce', 16, 'Delivery retrieval nonce') + return objectId('payment-delivery-retrieval', request.body) +} + +export async function validatePaymentAuthorization( + authorization: SignedObject, + demand: SignedObject, + currentTime?: LCHUint, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier(), + options: AcquisitionValidationOptions = {} +): Promise { + const demandId = await validatePaymentDemand(demand, verifier, options) + lchAssert( + demand.body.settlementProfile === LCH_SETTLEMENT_PROFILES.authorizedOutput, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Payment Demand does not permit authorized-output settlement' + ) + const payee = memberBytes(authorization.body, 'payee', 33, 'Authorization Payee') + await verifySignedObject('payment-authorization', authorization, verifier, payee) + lchAssert( + authorization.body.settlementProfile === LCH_SETTLEMENT_PROFILES.authorizedOutput, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Payment Authorization profile is unsupported' + ) + equal(memberBytes(authorization.body, 'demandId', 32, 'Demand ID'), demandId, 'Demand ID') + equalMember(authorization.body, demand.body, 'requestId', 32, 'Request ID') + equalMember(authorization.body, demand.body, 'payee', 33, 'Payee') + equalMember(authorization.body, demand.body, 'buyer', 33, 'Buyer') + equalMember(authorization.body, demand.body, 'derivationPrefix', 32, 'Derivation prefix') + lchAssert( + uint(authorization.body.satoshis, 'Authorization amount') === + uint(demand.body.satoshis, 'Demand amount'), + 'ERR_LCH_PAYMENT', + 'Payment Authorization amount does not match the Demand' + ) + memberBytes(authorization.body, 'derivationSuffix', 32, 'Derivation suffix') + const lockingScript = memberBytes( + authorization.body, + 'lockingScript', + undefined, + 'Authorized locking script' + ) + lchAssert(lockingScript.length <= 10_000, 'ERR_LCH_PAYMENT', 'Authorized script is too large') + const authorizedAt = uint(authorization.body.authorizedAt, 'Authorization time') + const authorizedUntil = uint(authorization.body.authorizedUntil, 'Authorization deadline') + const expiresAt = uint(demand.body.expiresAt, 'Demand expiry') + lchAssert( + authorizedAt < authorizedUntil && authorizedUntil <= expiresAt, + 'ERR_LCH_PAYMENT', + 'Payment Authorization deadlines are invalid' + ) + if (currentTime !== undefined) { + const current = uint(currentTime, 'Authorization validation time') + lchAssert( + authorizedAt <= current && current < authorizedUntil, + 'ERR_LCH_PAYMENT', + 'Payment Authorization is not currently valid' + ) + } + lchAssert( + uint(authorization.body.recoveryUntil, 'Authorization recovery deadline') === + uint(demand.body.recoveryUntil, 'Demand recovery deadline'), + 'ERR_LCH_PAYMENT', + 'Payment Authorization recovery deadline does not match the Demand' + ) + memberBytes(authorization.body, 'evidenceProvider', 33, 'Evidence provider') + memberBytes(authorization.body, 'deliveryProvider', 33, 'Delivery provider') + endpointMember(authorization.body, 'evidenceEndpoint', options) + endpointMember(authorization.body, 'deliveryEndpoint', options) + endpointMember(authorization.body, 'retrievalEndpoint', options) + lchAssert( + authorization.body.evidencePolicy === + LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance && + authorization.body.minimumTransactionState === 'accepted', + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'Payment Authorization evidence policy is unsupported' + ) + return objectId('payment-authorization', authorization.body) +} + +export async function validateAuthorizedOutputEvidence( + bundle: AuthorizedOutputEvidence, + demand: SignedObject, + atomicBeef: Uint8Array, + verifier: LCHSignatureVerifier = new PublicBRC77Verifier(), + options: AcquisitionValidationOptions = {} +): Promise { + const authorizationId = await validatePaymentAuthorization( + bundle.authorization, + demand, + undefined, + verifier, + options + ) + const deliveryId = await validatePaymentDelivery(bundle.delivery, verifier) + const demandId = await objectId('payment-demand', demand.body) + equal( + memberBytes(bundle.delivery.body, 'demandId', 32, 'Delivery Demand ID'), + demandId, + 'Demand ID' + ) + equalMember(bundle.delivery.body, demand.body, 'requestId', 32, 'Request ID') + equalMember(bundle.delivery.body, demand.body, 'buyer', 33, 'Buyer') + equalMember( + bundle.delivery.body, + bundle.authorization.body, + 'derivationPrefix', + 32, + 'Derivation prefix' + ) + equalMember( + bundle.delivery.body, + bundle.authorization.body, + 'derivationSuffix', + 32, + 'Derivation suffix' + ) + equal( + memberBytes(bundle.delivery.body, 'atomicBeef', undefined, 'Delivery Atomic BEEF'), + atomicBeef, + 'Completion Atomic BEEF' + ) + const transaction = parseAtomicBeef(atomicBeef) + const outputIndex = index(bundle.delivery.body.outputIndex) + const output = transaction.outputs[outputIndex] + lchAssert(output?.satoshis !== undefined, 'ERR_LCH_PAYMENT', 'Authorized output is absent') + lchAssert( + BigInt(output.satoshis) === uint(demand.body.satoshis, 'Demand amount'), + 'ERR_LCH_PAYMENT', + 'Authorized output amount does not match the Demand' + ) + equal( + output.lockingScript.toUint8Array(), + memberBytes(bundle.authorization.body, 'lockingScript', undefined, 'Authorized locking script'), + 'Authorized output locking script' + ) + await validateTransactionEvidence( + bundle.transactionEvidence, + bundle.authorization, + authorizationId, + transaction, + verifier + ) + await validateDeliveryAcknowledgement( + bundle.deliveryAcknowledgement, + bundle.authorization, + bundle.delivery, + authorizationId, + deliveryId, + verifier, + options + ) + return demandId +} + +export async function validateTransactionEvidence( + evidence: SignedObject, + authorization: SignedObject, + authorizationId: Uint8Array, + transaction: Transaction, + verifier: LCHSignatureVerifier +): Promise { + const provider = memberBytes(evidence.body, 'provider', 33, 'Evidence provider') + equal( + provider, + memberBytes(authorization.body, 'evidenceProvider', 33, 'Authorized evidence provider'), + 'Evidence provider' + ) + await verifySignedObject('transaction-evidence', evidence, verifier, provider) + equal( + memberBytes(evidence.body, 'authorizationId', 32, 'Payment Authorization ID'), + authorizationId, + 'Payment Authorization ID' + ) + equal( + memberBytes(evidence.body, 'txid', 32, 'Transaction ID'), + fromHex(transaction.id('hex')), + 'Transaction ID' + ) + const state = memberString(evidence.body, 'state') as LCHTransactionState + const minimum = memberString(authorization.body, 'minimumTransactionState') as LCHTransactionState + lchAssert( + state === 'accepted' && + minimum === 'accepted' && + transactionStateRank(state) >= transactionStateRank(minimum), + 'ERR_LCH_PAYMENT', + 'Transaction evidence does not meet the authorized minimum state' + ) + lchAssert( + memberString(evidence.body, 'policy') === memberString(authorization.body, 'evidencePolicy'), + 'ERR_LCH_PAYMENT', + 'Transaction evidence policy does not match the Authorization' + ) + const observedAt = uint(evidence.body.observedAt, 'Transaction evidence time') + lchAssert( + observedAt >= uint(authorization.body.authorizedAt, 'Authorization time') && + observedAt < uint(authorization.body.recoveryUntil, 'Recovery deadline'), + 'ERR_LCH_PAYMENT', + 'Transaction evidence time is outside the authorized recovery window' + ) +} + +export async function validateDeliveryAcknowledgement( + acknowledgement: SignedObject, + authorization: SignedObject, + delivery: SignedObject, + authorizationId: Uint8Array, + deliveryId: Uint8Array, + verifier: LCHSignatureVerifier, + options: AcquisitionValidationOptions +): Promise { + const provider = memberBytes(acknowledgement.body, 'provider', 33, 'Delivery provider') + equal( + provider, + memberBytes(authorization.body, 'deliveryProvider', 33, 'Authorized delivery provider'), + 'Delivery provider' + ) + await verifySignedObject('payment-delivery-ack', acknowledgement, verifier, provider) + equal( + memberBytes(acknowledgement.body, 'authorizationId', 32, 'Payment Authorization ID'), + authorizationId, + 'Payment Authorization ID' + ) + equal( + memberBytes(acknowledgement.body, 'deliveryId', 32, 'Payment Delivery ID'), + deliveryId, + 'Payment Delivery ID' + ) + equalMember(acknowledgement.body, delivery.body, 'demandId', 32, 'Demand ID') + equalMember(acknowledgement.body, delivery.body, 'requestId', 32, 'Request ID') + equalMember(acknowledgement.body, authorization.body, 'payee', 33, 'Payee') + const storedAt = uint(acknowledgement.body.storedAt, 'Delivery storage time') + const availableUntil = uint(acknowledgement.body.availableUntil, 'Delivery availability deadline') + lchAssert( + storedAt >= uint(authorization.body.authorizedAt, 'Authorization time') && + storedAt < uint(authorization.body.recoveryUntil, 'Recovery deadline') && + storedAt < availableUntil && + availableUntil >= uint(authorization.body.recoveryUntil, 'Recovery deadline'), + 'ERR_LCH_DELIVERY', + 'Delivery is not retained through the recovery deadline' + ) + const retrieval = endpointMember(acknowledgement.body, 'retrievalEndpoint', options) + lchAssert( + retrieval === memberString(authorization.body, 'retrievalEndpoint'), + 'ERR_LCH_DELIVERY', + 'Delivery retrieval endpoint does not match the Authorization' + ) +} + +function transactionStateRank(value: LCHTransactionState): number { + const states: LCHTransactionState[] = ['finalized', 'broadcast', 'accepted', 'mined'] + const rank = states.indexOf(value) + lchAssert(rank >= 0, 'ERR_LCH_PAYMENT', 'Transaction evidence state is invalid') + return rank +} + +function parseAtomicBeef(value: Uint8Array): Transaction { + try { + return Transaction.fromAtomicBEEF(value as AtomicBEEF) + } catch (error) { + throw new Error('Authorized-output Atomic BEEF could not be parsed', { cause: error }) + } +} + +function secureRandom(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) +} + +function bytes( + value: unknown, + length: number | undefined, + name: string +): asserts value is Uint8Array { + lchAssert( + value instanceof Uint8Array && + value.length > 0 && + (length === undefined || value.length === length), + 'ERR_LCH_PAYMENT', + `${name} is invalid` + ) +} + +function memberBytes( + body: Record, + key: string, + length: number | undefined, + name: string +): Uint8Array { + const value = body[key] + bytes(value, length, name) + return value +} + +function memberString(body: Record, key: string): string { + const value = body[key] + lchAssert(typeof value === 'string' && value.length > 0, 'ERR_LCH_PAYMENT', `${key} is absent`) + return value +} + +function uint(value: unknown, name: string): bigint { + lchAssert( + typeof value === 'bigint' || (typeof value === 'number' && Number.isSafeInteger(value)), + 'ERR_LCH_PAYMENT', + `${name} must be an exact integer` + ) + const result = BigInt(value) + lchAssert(result >= 0n && result <= MAX_UINT64, 'ERR_LCH_PAYMENT', `${name} is outside uint64`) + return result +} + +function index(value: unknown): number { + lchAssert( + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0, + 'ERR_LCH_PAYMENT', + 'Payment output index is invalid' + ) + return value +} + +function equal(value: Uint8Array, expected: Uint8Array, name: string): void { + lchAssert(toHex(value) === toHex(expected), 'ERR_LCH_PAYMENT', `${name} does not match`) +} + +function equalMember( + left: Record, + right: Record, + key: string, + length: number, + name: string +): void { + equal(memberBytes(left, key, length, name), memberBytes(right, key, length, name), name) +} + +function endpointMember( + body: Record, + key: string, + options: AcquisitionValidationOptions +): string { + const value = memberString(body, key) + let origin = '' + try { + origin = new URL(value).origin + } catch { + lchAssert(false, 'ERR_LCH_ENDPOINT', 'Settlement endpoint is not an absolute URL') + } + const local = options.allowInsecureLocalOrigins?.includes(origin) === true + endpoint(value, local) + return value +} + +function endpoint(value: string, allowInsecureLocal = false): void { + let parsed: URL + try { + parsed = new URL(value) + } catch { + lchAssert(false, 'ERR_LCH_ENDPOINT', 'Settlement endpoint is not an absolute URL') + } + lchAssert( + (parsed.protocol === 'https:' || + (allowInsecureLocal && + parsed.protocol === 'http:' && + ['127.0.0.1', '[::1]', 'localhost'].includes(parsed.hostname))) && + parsed.username === '' && + parsed.password === '' && + parsed.hash === '', + 'ERR_LCH_ENDPOINT', + 'Settlement endpoint must be HTTPS without userinfo or fragment' + ) +} diff --git a/packages/content/lch/src/signatures.ts b/packages/content/lch/src/signatures.ts new file mode 100644 index 000000000..b190f7c61 --- /dev/null +++ b/packages/content/lch/src/signatures.ts @@ -0,0 +1,81 @@ +import { SignedMessage, Utils } from '@bsv/sdk' +import { LCH_SIGNING_PROTOCOL } from './constants.js' +import { lchAssert } from './errors.js' +import { concatBytes, fromHex, toBase64Url } from './hash.js' +import type { LCHSignatureVerifier, LCHSigner, WalletSignerOptions } from './types.js' + +const BRC77_VERSION = Uint8Array.of(0x42, 0x42, 0x33, 0x01) + +function defaultRandom(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) +} + +export class WalletBRC77Signer implements LCHSigner { + readonly identityKey: Uint8Array + + private constructor( + identityKey: Uint8Array, + private readonly options: WalletSignerOptions + ) { + this.identityKey = identityKey + } + + static async create(options: WalletSignerOptions): Promise { + const identity = + options.identityKey ?? (await options.wallet.getPublicKey({ identityKey: true })).publicKey + const identityKey = fromHex(identity) + lchAssert( + identityKey.length === 33, + 'ERR_LCH_SIGNATURE', + 'Wallet identity key must be compressed' + ) + return new WalletBRC77Signer(identityKey, options) + } + + async sign(preimage: Uint8Array): Promise { + const keyId = (this.options.random ?? defaultRandom)(32) + lchAssert( + keyId.length === 32, + 'ERR_LCH_SIGNATURE', + 'Signature random source returned invalid key ID' + ) + const { signature } = await this.options.wallet.createSignature({ + data: Array.from(preimage), + protocolID: [...LCH_SIGNING_PROTOCOL], + keyID: Utils.toBase64(Array.from(keyId)), + counterparty: 'anyone' + }) + return concatBytes( + BRC77_VERSION, + this.identityKey, + Uint8Array.of(0), + keyId, + Uint8Array.from(signature) + ) + } +} + +export class PublicBRC77Verifier implements LCHSignatureVerifier { + async verify(preimage: Uint8Array, signature: Uint8Array): Promise { + try { + return SignedMessage.verify(Array.from(preimage), Array.from(signature)) + } catch { + return false + } + } +} + +export function brc77SignerIdentity(signature: Uint8Array): Uint8Array { + lchAssert(signature.length >= 70, 'ERR_LCH_SIGNATURE', 'Truncated BRC-77 signature') + lchAssert( + BRC77_VERSION.every((byte, index) => signature[index] === byte), + 'ERR_LCH_SIGNATURE', + 'Invalid BRC-77 version' + ) + return signature.slice(4, 37) +} + +export function brc78KeyId(keyId: Uint8Array): string { + lchAssert(keyId.length === 32, 'ERR_LCH_KEY', 'BRC-78 Key ID must contain 32 bytes') + return toBase64Url(keyId) +} diff --git a/packages/content/lch/src/storage.ts b/packages/content/lch/src/storage.ts new file mode 100644 index 000000000..e940539d5 --- /dev/null +++ b/packages/content/lch/src/storage.ts @@ -0,0 +1,241 @@ +import { StorageDownloader, StorageUploader } from '@bsv/sdk' +import { lchAssert } from './errors.js' +import { fetchLCH, type EndpointPolicy } from './endpoints.js' +import type { ContentSink, ContentSource, LicenseStore, StoredLicense } from './types.js' + +export type CHIRPInteger = number | bigint | string + +export interface CHIRPDownloadAdapter { + download( + locator: string, + options?: { range?: { start: bigint; endExclusive: bigint } } + ): Promise<{ data: Uint8Array }> +} + +export interface CHIRPUploadAdapter { + publish(options: { + source: Uint8Array + retentionSeconds: CHIRPInteger + logicalLength: CHIRPInteger + mediaType?: string + }): Promise<{ chirpURL: string }> +} + +export interface UniversalContentSourceOptions { + chirp?: CHIRPDownloadAdapter + uhrp?: StorageDownloader + endpointPolicy?: EndpointPolicy + maximumBytes?: number +} + +export class UniversalContentSource implements ContentSource { + constructor(private readonly options: UniversalContentSourceOptions = {}) {} + + async read(locator: string, start?: bigint, end?: bigint): Promise { + const maximum = this.options.maximumBytes ?? 512 * 1024 * 1024 + if (locator.startsWith('chirp://')) { + lchAssert( + this.options.chirp !== undefined, + 'ERR_LCH_PROFILE_UNSUPPORTED', + 'No CHIRP downloader is configured' + ) + const data = ( + await this.options.chirp.download( + locator, + start === undefined || end === undefined + ? undefined + : { range: { start, endExclusive: end } } + ) + ).data + lchAssert( + data.length <= maximum, + 'ERR_LCH_CONTENT_UNAVAILABLE', + 'Content exceeds download limit' + ) + return data + } + if (locator.startsWith('uhrp://')) { + const downloader = this.options.uhrp ?? new StorageDownloader({ networkPreset: 'mainnet' }) + const locations = await downloader.resolve(locator) + lchAssert(locations.length > 0, 'ERR_LCH_CONTENT_UNAVAILABLE', 'No UHRP host resolved') + return this.read(locations[0], start, end) + } + const headers = new Headers() + if (start !== undefined && end !== undefined) headers.set('range', `bytes=${start}-${end - 1n}`) + const response = await fetchLCH(locator, { headers }, 'content', this.options.endpointPolicy) + lchAssert( + response.ok, + 'ERR_LCH_CONTENT_UNAVAILABLE', + `Content host returned ${response.status}` + ) + const declared = response.headers.get('content-length') + if (declared !== null) + lchAssert( + Number(declared) <= maximum, + 'ERR_LCH_CONTENT_UNAVAILABLE', + 'Content exceeds download limit' + ) + return readBoundedBody(response, maximum) + } +} + +async function readBoundedBody(response: Response, maximum: number): Promise { + lchAssert( + Number.isSafeInteger(maximum) && maximum >= 0, + 'ERR_LCH_CONTENT_UNAVAILABLE', + 'Download limit is invalid' + ) + if (response.body === null) return new Uint8Array() + const reader = response.body.getReader() + const parts: Uint8Array[] = [] + let total = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.length + lchAssert(total <= maximum, 'ERR_LCH_CONTENT_UNAVAILABLE', 'Content exceeds download limit') + parts.push(value) + } + } catch (error) { + await reader.cancel(error).catch(() => undefined) + throw error + } + const result = new Uint8Array(total) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +export class CHIRPContentSink implements ContentSink { + constructor( + private readonly uploader: CHIRPUploadAdapter, + private readonly retentionSeconds: CHIRPInteger, + private readonly mediaType = 'application/octet-stream' + ) {} + + async put(ciphertext: Uint8Array): Promise { + const result = await this.uploader.publish({ + source: ciphertext, + retentionSeconds: this.retentionSeconds, + logicalLength: ciphertext.length, + mediaType: this.mediaType + }) + return [result.chirpURL] + } +} + +export class UHRPContentSink implements ContentSink { + constructor( + private readonly uploader: StorageUploader, + private readonly retentionPeriod: number, + private readonly mediaType = 'application/octet-stream' + ) {} + + async put(ciphertext: Uint8Array): Promise { + const result = await this.uploader.publishFile({ + file: { data: ciphertext, type: this.mediaType }, + retentionPeriod: this.retentionPeriod + }) + return [result.uhrpURL] + } +} + +export class MemoryContentSink implements ContentSink, ContentSource { + private readonly content = new Map() + private next = 0 + + async put(ciphertext: Uint8Array): Promise { + const locator = `memory://lch/${this.next}` + this.next += 1 + this.content.set(locator, ciphertext.slice()) + return [locator] + } + + async read(locator: string, start = 0n, end?: bigint): Promise { + const bytes = this.content.get(locator) + lchAssert(bytes !== undefined, 'ERR_LCH_CONTENT_UNAVAILABLE', 'Memory content is unavailable') + return bytes.slice(Number(start), end === undefined ? undefined : Number(end)) + } +} + +export class MemoryLicenseStore implements LicenseStore { + private readonly records = new Map() + + async get(assetId: string, offerId?: string): Promise { + if (offerId !== undefined) return this.records.get(`${assetId}:${offerId}`) + return Array.from(this.records.values()).find(record => record.assetId === assetId) + } + + async put(record: StoredLicense): Promise { + this.records.set(`${record.assetId}:${record.offerId}`, record) + } + + async delete(assetId: string, offerId: string): Promise { + this.records.delete(`${assetId}:${offerId}`) + } +} + +export class IndexedDBLicenseStore implements LicenseStore { + constructor( + private readonly databaseName = 'bsv-lch', + private readonly storeName = 'licenses' + ) {} + + async get(assetId: string, offerId?: string): Promise { + const all = await this.all() + return all.find( + record => record.assetId === assetId && (offerId === undefined || record.offerId === offerId) + ) + } + + async put(record: StoredLicense): Promise { + const database = await this.open() + await transactionPromise(database, this.storeName, 'readwrite', store => + store.put(record, `${record.assetId}:${record.offerId}`) + ) + } + + async delete(assetId: string, offerId: string): Promise { + const database = await this.open() + await transactionPromise(database, this.storeName, 'readwrite', store => + store.delete(`${assetId}:${offerId}`) + ) + } + + private async all(): Promise { + const database = await this.open() + return transactionPromise(database, this.storeName, 'readonly', store => + store.getAll() + ) as Promise + } + + private async open(): Promise { + lchAssert(typeof indexedDB !== 'undefined', 'ERR_LCH_LICENSE', 'IndexedDB is unavailable') + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.databaseName, 1) + request.onupgradeneeded = () => request.result.createObjectStore(this.storeName) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')) + }) + } +} + +async function transactionPromise( + database: IDBDatabase, + storeName: string, + mode: IDBTransactionMode, + action: (store: IDBObjectStore) => IDBRequest +): Promise { + return new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, mode) + const request = action(transaction.objectStore(storeName)) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')) + transaction.onabort = () => + reject(transaction.error ?? new Error('IndexedDB transaction aborted')) + }) +} diff --git a/packages/content/lch/src/time.ts b/packages/content/lch/src/time.ts new file mode 100644 index 000000000..b15e4140b --- /dev/null +++ b/packages/content/lch/src/time.ts @@ -0,0 +1,45 @@ +import { lchAssert } from './errors.js' +import type { LCHUint } from './types.js' + +export interface LCHTimeWindow { + notBefore?: LCHUint + notAfter?: LCHUint +} + +export type LCHTimeWindowStatus = 'not-started' | 'active' | 'expired' + +function timestamp(value: LCHUint, field: string): bigint { + lchAssert( + typeof value === 'bigint' ? value >= 0n : Number.isSafeInteger(value) && value >= 0, + 'ERR_LCH_LICENSE', + `${field} must be an unsigned integer timestamp` + ) + return BigInt(value) +} + +export function validateTimeWindow(window: LCHTimeWindow): void { + const start = + window.notBefore === undefined ? undefined : timestamp(window.notBefore, 'notBefore') + const end = window.notAfter === undefined ? undefined : timestamp(window.notAfter, 'notAfter') + lchAssert( + start === undefined || end === undefined || start < end, + 'ERR_LCH_LICENSE', + 'Time window must be nonempty' + ) +} + +export function timeWindowStatus(window: LCHTimeWindow, now: LCHUint): LCHTimeWindowStatus { + validateTimeWindow(window) + const instant = timestamp(now, 'now') + if (window.notBefore !== undefined && instant < BigInt(window.notBefore)) return 'not-started' + if (window.notAfter !== undefined && instant >= BigInt(window.notAfter)) return 'expired' + return 'active' +} + +export function requireActiveTimeWindow(window: LCHTimeWindow, now: LCHUint): void { + lchAssert( + timeWindowStatus(window, now) === 'active', + 'ERR_LCH_LICENSE', + 'License is outside its active time window' + ) +} diff --git a/packages/content/lch/src/types.ts b/packages/content/lch/src/types.ts new file mode 100644 index 000000000..6ec45a6fd --- /dev/null +++ b/packages/content/lch/src/types.ts @@ -0,0 +1,148 @@ +import type { WalletInterface } from '@bsv/sdk' + +export type LCHUint = number | bigint +export type LCHValue = + null | boolean | LCHUint | string | Uint8Array | LCHValue[] | { [key: string]: LCHValue } + +export type LCHObjectType = + | 'asset' + | 'header' + | 'authority' + | 'offer' + | 'selection' + | 'license-request' + | 'quote' + | 'payment-demand' + | 'payment-readiness' + | 'payment-authorization' + | 'payment-delivery' + | 'payment-delivery-retrieval' + | 'transaction-evidence' + | 'payment-delivery-ack' + | 'payment-receipt' + | 'license' + | 'composition-record' + +export interface SignedObject = Record> { + body: T + signatures: Uint8Array[] +} + +export type RangeTuple = readonly [start: LCHUint, end: LCHUint] +export type Selection = + | { type: 'all' } + | { type: 'segments' | 'bytes' | 'pages'; ranges: RangeTuple[] } + | { type: 'media-fragment'; value: string } + +export interface KeyPeriod { + keyId: Uint8Array + firstSegment: LCHUint + segmentCount: LCHUint +} + +export interface KeyGrant { + keyId: Uint8Array + delivery: string + payload: Uint8Array +} + +export interface SegmentedEncryptionDescriptor { + algorithm: string + encryptionId: Uint8Array + plaintextLength: LCHUint + segmentSize: LCHUint + segmentCount: LCHUint + noncePrefix: Uint8Array + keyPeriods: KeyPeriod[] +} + +export interface EncryptionResult { + ciphertext: Uint8Array + descriptor: SegmentedEncryptionDescriptor + keys: Map +} + +export interface ContentReference { + ciphertextDigest: Uint8Array + ciphertextLength: LCHUint + plaintextDigest?: Uint8Array + encryption: SegmentedEncryptionDescriptor + locators: string[] +} + +export interface ContentSource { + read(locator: string, start?: bigint, end?: bigint): Promise +} + +export interface ContentSink { + put(ciphertext: Uint8Array): Promise +} + +export interface LCHSigner { + identityKey: Uint8Array + sign(preimage: Uint8Array): Promise +} + +export interface LCHSignatureVerifier { + verify(preimage: Uint8Array, signature: Uint8Array): Promise +} + +export interface WalletSignerOptions { + wallet: Pick + identityKey?: string + random?: (length: number) => Uint8Array +} + +export interface RevocationObservation { + status: 'unspent' | 'spent-mempool' | 'spent-confirmed' | 'unknown' + network: 'mainnet' | 'testnet' + observedAt: bigint + blockHeight?: bigint + tipHash?: string + reorganizationAffected?: boolean +} + +export interface RevocationSource { + status(outpoint: string): Promise +} + +export interface StoredLicense { + assetId: string + offerId: string + license: SignedObject + storedAt: bigint +} + +export interface LicenseStore { + get(assetId: string, offerId?: string): Promise + put(record: StoredLicense): Promise + delete(assetId: string, offerId: string): Promise +} + +export interface C2PAIngredientBinding { + sourceAssetId: Uint8Array + relationship: 'componentOf' | 'inputTo' + hashedUri: { url: string; alg?: string; hash: Uint8Array } +} + +export interface C2PAAdapter { + validate(asset: Uint8Array, manifest?: Uint8Array): Promise +} + +export interface PaymentOutput { + satoshis: bigint + lockingScript: Uint8Array + outputIndex?: number +} + +export interface PaymentDemand { + demandId: Uint8Array + satoshis: bigint + lockingScript: Uint8Array +} + +/** + * The strongest transaction lifecycle state directly established by the + * wallet result. Later states require separately authenticated evidence. + */ +export type LCHTransactionState = 'finalized' | 'broadcast' | 'accepted' | 'mined' diff --git a/packages/content/lch/src/walletPayment.ts b/packages/content/lch/src/walletPayment.ts new file mode 100644 index 000000000..f5d7b4bbc --- /dev/null +++ b/packages/content/lch/src/walletPayment.ts @@ -0,0 +1,151 @@ +import { P2PKH, PublicKey, Transaction, type AtomicBEEF, type WalletInterface } from '@bsv/sdk' +import { lchAssert } from './errors.js' +import { toBase64Url, toHex } from './hash.js' +import { checkedSatoshis, matchFinalizedOutputs } from './payment.js' +import type { LCHTransactionState, PaymentDemand, PaymentOutput } from './types.js' + +export const BRC29_PAYMENT_PROTOCOL = [2, '3241645161d8'] as const + +export interface MultipayDemand { + demandId: Uint8Array + payee: Uint8Array + satoshis: bigint + derivationPrefix: Uint8Array + dutyUid: string + authorizedOutput?: { + derivationSuffix: Uint8Array + lockingScript: Uint8Array + } +} + +export interface MultipayRemittance { + demandId: Uint8Array + derivationPrefix: Uint8Array + derivationSuffix: Uint8Array + outputIndex: number +} + +export interface MultipayResult { + atomicBeef: Uint8Array + remittances: MultipayRemittance[] + transactionState: Extract +} + +export interface MultipayWalletOptions { + description?: string + labels?: string[] + random?: (length: number) => Uint8Array +} + +function secureRandom(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) +} + +export async function createMultipayTransaction( + wallet: Pick, + demands: readonly MultipayDemand[], + options: MultipayWalletOptions = {} +): Promise { + lchAssert( + demands.length > 1, + 'ERR_LCH_PAYMENT', + 'Multilateral payment requires more than one Demand' + ) + const random = options.random ?? secureRandom + const planned: Array<{ demand: MultipayDemand; suffix: Uint8Array; payment: PaymentDemand }> = [] + for (const demand of demands) { + lchAssert( + demand.demandId.length === 32 && + demand.payee.length === 33 && + demand.derivationPrefix.length === 32, + 'ERR_LCH_PAYMENT', + 'Demand payment fields have invalid lengths' + ) + lchAssert(demand.dutyUid.length > 0, 'ERR_LCH_PAYMENT', 'Demand duty UID is absent') + const satoshis = checkedSatoshis(demand.satoshis) + const suffix = demand.authorizedOutput?.derivationSuffix ?? random(32) + lchAssert( + suffix.length === 32, + 'ERR_LCH_PAYMENT', + 'Random source returned invalid derivation suffix' + ) + const keyID = `${toBase64Url(demand.derivationPrefix)} ${toBase64Url(suffix)}` + const { publicKey } = await wallet.getPublicKey({ + protocolID: [...BRC29_PAYMENT_PROTOCOL], + keyID, + counterparty: toHex(demand.payee) + }) + const lockingScript = new P2PKH().lock(PublicKey.fromString(publicKey).toAddress()) + if (demand.authorizedOutput !== undefined) { + lchAssert( + demand.authorizedOutput.lockingScript.length > 0 && + toHex(lockingScript.toUint8Array()) === toHex(demand.authorizedOutput.lockingScript), + 'ERR_LCH_PAYMENT', + 'Wallet-derived output does not match the Payee Authorization' + ) + } + planned.push({ + demand, + suffix, + payment: { + demandId: demand.demandId, + satoshis, + lockingScript: lockingScript.toUint8Array() + } + }) + } + const action = await wallet.createAction({ + description: options.description ?? 'LCH multilateral license payment', + labels: options.labels ?? ['lch multipay'], + outputs: planned.map(({ demand, suffix, payment }) => ({ + satoshis: Number(payment.satoshis), + lockingScript: toHex(payment.lockingScript), + outputDescription: `LCH duty ${demand.dutyUid}`, + customInstructions: JSON.stringify({ + derivationPrefix: toBase64Url(demand.derivationPrefix), + derivationSuffix: toBase64Url(suffix), + payee: toHex(demand.payee) + }) + })) + }) + lchAssert( + action.tx !== undefined, + 'ERR_LCH_PAYMENT', + 'Wallet did not return finalized Atomic BEEF' + ) + const transaction = Transaction.fromAtomicBEEF(action.tx as AtomicBEEF) + const outputs: PaymentOutput[] = transaction.outputs.map((output, outputIndex) => { + lchAssert( + output.satoshis !== undefined, + 'ERR_LCH_PAYMENT', + 'Finalized output has no satoshi amount' + ) + return { + satoshis: BigInt(output.satoshis), + lockingScript: output.lockingScript.toUint8Array(), + outputIndex + } + }) + const matches = matchFinalizedOutputs( + planned.map(item => item.payment), + outputs + ) + return { + atomicBeef: Uint8Array.from(action.tx), + transactionState: 'finalized', + remittances: planned.map(({ demand, suffix }) => { + const outputIndex = matches.get(toHex(demand.demandId)) + lchAssert( + outputIndex !== undefined, + 'ERR_LCH_PAYMENT', + 'Finalized Demand output was not matched' + ) + return { + demandId: demand.demandId, + derivationPrefix: demand.derivationPrefix, + derivationSuffix: suffix, + outputIndex + } + }) + } +} diff --git a/packages/content/lch/test/acquisition-server.test.ts b/packages/content/lch/test/acquisition-server.test.ts new file mode 100644 index 000000000..81343dd4d --- /dev/null +++ b/packages/content/lch/test/acquisition-server.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { LockingScript, P2PKH, PrivateKey, ProtoWallet, Transaction } from '@bsv/sdk' +import { + LCHBuyer, + LCHPayee, + LCHQuoteIssuer, + MemoryPaymentLedger, + WalletBRC77Signer, + WalletPaymentReceiver, + objectId, + signObject, + toHex, + validateLicenseRequest, + validatePaymentDemand, + validatePaymentReceipt, + validatePaymentReadiness, + validateQuote +} from '../src/index.js' + +const bytes = (value: number, length: number): Uint8Array => new Uint8Array(length).fill(value) + +describe('typed acquisition messages', () => { + it('constructs a signed request and quote with exact demand totals and deadlines', async () => { + const buyerSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(41)), + random: length => bytes(1, length) + }) + const issuerSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(42)), + random: length => bytes(2, length) + }) + const payeeSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(43)), + random: length => bytes(3, length) + }) + const request = await new LCHBuyer(buyerSigner, length => bytes(4, length)).createRequest({ + offerId: bytes(5, 32), + assetId: bytes(6, 32), + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest: bytes(7, 32), + createdAt: 1_000 + }) + const requestId = await validateLicenseRequest(request) + const demand = await new LCHPayee(payeeSigner, length => bytes(8, length)).createDemand({ + requestId, + offerId: bytes(5, 32), + dutyUid: 'urn:lch:duty:recording', + buyer: buyerSigner.identityKey, + endpoint: 'https://payee.example/lch', + satoshis: 12, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400 + }) + const quote = await new LCHQuoteIssuer(issuerSigner).createQuote({ + requestId, + offerId: bytes(5, 32), + assetId: bytes(6, 32), + buyer: buyerSigner.identityKey, + selection: { type: 'all' }, + demands: [demand], + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400 + }) + expect(quote.body.totalSatoshis).toBe(12n) + expect(quote.body.recoveryUntil).toBe(88_400n) + expect(await objectId('license-request', request.body)).toEqual(requestId) + await expect(validateQuote(quote, request, issuerSigner.identityKey)).resolves.toEqual( + await objectId('quote', quote.body) + ) + const readiness = await new LCHPayee(payeeSigner).createReadiness({ + demandId: await objectId('payment-demand', demand.body), + requestId, + buyer: buyerSigner.identityKey, + issuedAt: 1_000, + readyUntil: 1_100, + recoveryUntil: demand.body.recoveryUntil as number | bigint + }) + await expect(validatePaymentReadiness(readiness, demand, 1_050)).resolves.toEqual( + await objectId('payment-readiness', readiness.body) + ) + await expect(validatePaymentReadiness(readiness, demand, 1_100)).rejects.toMatchObject({ + code: 'ERR_LCH_PAYMENT' + }) + const dishonestTotal = await signObject( + 'quote', + { ...quote.body, totalSatoshis: 13 }, + issuerSigner + ) + await expect( + validateQuote(dishonestTotal, request, issuerSigner.identityKey) + ).rejects.toMatchObject({ code: 'ERR_LCH_PAYMENT' }) + }) + + it('rejects malformed request digests before signing', async () => { + const signer = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(44)) + }) + await expect( + new LCHBuyer(signer).createRequest({ + offerId: bytes(1, 32), + assetId: bytes(2, 32), + action: 'read', + selection: { type: 'all' }, + acceptedPolicyDigest: bytes(3, 31), + createdAt: 1 + }) + ).rejects.toMatchObject({ code: 'ERR_LCH_FRAMING' }) + }) + + it('permits HTTP loopback only when that exact local origin is enumerated', async () => { + const signer = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(45)) + }) + const demand = await new LCHPayee(signer).createDemand({ + requestId: bytes(1, 32), + offerId: bytes(2, 32), + dutyUid: 'urn:lch:duty:local', + buyer: bytes(3, 33), + endpoint: 'http://127.0.0.1:4173/api/lch', + satoshis: 1, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400, + allowInsecureLocalEndpoint: true + }) + await expect(validatePaymentDemand(demand)).rejects.toMatchObject({ + code: 'ERR_LCH_ENDPOINT' + }) + await expect( + validatePaymentDemand(demand, undefined, { + allowInsecureLocalOrigins: ['http://127.0.0.1:4173'] + }) + ).resolves.toBeInstanceOf(Uint8Array) + }) + + it('validates optional term acceptances, critical identifiers, and absolute endpoints', async () => { + const buyerSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(46)) + }) + const payeeSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(47)) + }) + await expect( + new LCHBuyer(buyerSigner).createRequest({ + offerId: bytes(1, 32), + assetId: bytes(2, 32), + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest: bytes(3, 32), + acceptedHumanTermDigests: [bytes(4, 32)], + createdAt: 1, + critical: ['https://example.test/lch/critical-v1'] + }) + ).resolves.toBeDefined() + await expect( + new LCHBuyer(buyerSigner).createRequest({ + offerId: bytes(1, 32), + assetId: bytes(2, 32), + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest: bytes(3, 32), + createdAt: 1, + critical: ['https://example.test/repeated', 'https://example.test/repeated'] + }) + ).rejects.toMatchObject({ code: 'ERR_LCH_PROFILE_UNSUPPORTED' }) + await expect( + new LCHBuyer(buyerSigner).createRequest({ + offerId: bytes(1, 32), + assetId: bytes(2, 32), + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest: bytes(3, 32), + createdAt: 1, + critical: ['not-an-absolute-identifier'] + }) + ).rejects.toMatchObject({ code: 'ERR_LCH_PROFILE_UNSUPPORTED' }) + + const demand = await new LCHPayee(payeeSigner).createDemand({ + requestId: bytes(5, 32), + offerId: bytes(6, 32), + dutyUid: 'urn:lch:duty:endpoint', + buyer: buyerSigner.identityKey, + endpoint: 'https://payee.example/lch', + satoshis: 1, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400 + }) + const malformedEndpoint = await signObject( + 'payment-demand', + { ...demand.body, endpoint: 'not-an-absolute-url' }, + payeeSigner + ) + await expect( + validatePaymentDemand(malformedEndpoint, undefined, { + allowInsecureLocalOrigins: ['https://payee.example'] + }) + ).rejects.toMatchObject({ code: 'ERR_LCH_ENDPOINT' }) + }) +}) + +describe('wallet-backed payee receiver', () => { + it('preflights with the receiver clock and rejects malformed Atomic BEEF', async () => { + const buyerSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(48)) + }) + const payeeSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(49)) + }) + const receiver = new WalletPaymentReceiver({ + wallet: { + getPublicKey: async () => ({ publicKey: new PrivateKey(50).toPublicKey().toString() }), + internalizeAction: async () => ({ accepted: true }) + } as never, + signer: payeeSigner + }) + const requestId = bytes(10, 32) + const demand = await new LCHPayee(payeeSigner).createDemand({ + requestId, + offerId: bytes(11, 32), + dutyUid: 'urn:lch:duty:preflight', + buyer: buyerSigner.identityKey, + endpoint: 'https://payee.example/lch', + satoshis: 1, + expiresAt: 4_000_000_000, + recoveryPeriodSeconds: 86_400 + }) + await expect(receiver.preflight(demand)).resolves.toBeUndefined() + const delivery = await new LCHBuyer(buyerSigner).createPaymentDelivery({ + demandId: await objectId('payment-demand', demand.body), + requestId, + atomicBeef: bytes(12, 3), + outputIndex: 0, + derivationPrefix: demand.body.derivationPrefix as Uint8Array, + derivationSuffix: bytes(13, 32) + }) + await expect(receiver.receive(demand, delivery)).rejects.toThrow(/could not be parsed/u) + }) + + it('derives, verifies, internalizes, receipts, and idempotently redelivers one output', async () => { + const buyerSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(51)), + random: length => bytes(1, length) + }) + const payeeSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(52)), + random: length => bytes(2, length) + }) + const payeePublicKey = new PrivateKey(53).toPublicKey() + const internalizeAction = jest.fn(async () => ({ accepted: true, isMerge: false })) + const receiverWallet = { + getPublicKey: jest.fn(async () => ({ publicKey: payeePublicKey.toString() })), + internalizeAction + } + const requestId = bytes(4, 32) + const demand = await new LCHPayee(payeeSigner, length => bytes(5, length)).createDemand({ + requestId, + offerId: bytes(6, 32), + dutyUid: 'urn:lch:duty:master', + buyer: buyerSigner.identityKey, + endpoint: 'https://payee.example/lch', + satoshis: 7, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400 + }) + const demandId = await objectId('payment-demand', demand.body) + const lockingScript = new P2PKH().lock(payeePublicKey.toAddress()).toUint8Array() + const transaction = new Transaction( + 1, + [], + [{ satoshis: 7, lockingScript: LockingScript.fromHex(toHex(lockingScript)) }] + ) + const atomicBeef = Uint8Array.from(transaction.toAtomicBEEF(true)) + const delivery = await new LCHBuyer(buyerSigner).createPaymentDelivery({ + demandId, + requestId, + atomicBeef, + outputIndex: 0, + derivationPrefix: demand.body.derivationPrefix as Uint8Array, + derivationSuffix: bytes(8, 32) + }) + const receiver = new WalletPaymentReceiver({ + wallet: receiverWallet as never, + signer: payeeSigner, + ledger: new MemoryPaymentLedger(), + now: () => 2_001n + }) + const otherBuyer = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(54)) + }) + const wrongBuyerDelivery = await new LCHBuyer(otherBuyer).createPaymentDelivery({ + demandId, + requestId, + atomicBeef, + outputIndex: 0, + derivationPrefix: demand.body.derivationPrefix as Uint8Array, + derivationSuffix: bytes(8, 32) + }) + await expect(receiver.receive(demand, wrongBuyerDelivery)).rejects.toMatchObject({ + code: 'ERR_LCH_PAYMENT' + }) + const first = await receiver.receive(demand, delivery) + const repeated = await receiver.receive(demand, delivery) + expect(repeated).toBe(first) + expect(internalizeAction).toHaveBeenCalledTimes(1) + expect(internalizeAction.mock.calls[0]?.[0]).toMatchObject({ + outputs: [{ outputIndex: 0, protocol: 'wallet payment' }] + }) + await expect(validatePaymentReceipt(first)).resolves.toBeInstanceOf(Uint8Array) + }) + + it('rejects a conflicting transaction for a claimed Demand', async () => { + const buyerSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(61)) + }) + const payeeSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(62)) + }) + const publicKey = new PrivateKey(63).toPublicKey() + const receiver = new WalletPaymentReceiver({ + wallet: { + getPublicKey: async () => ({ publicKey: publicKey.toString() }), + internalizeAction: async () => ({ accepted: true }) + } as never, + signer: payeeSigner, + now: () => 100n + }) + const requestId = bytes(1, 32) + const demand = await new LCHPayee(payeeSigner, length => bytes(2, length)).createDemand({ + requestId, + offerId: bytes(3, 32), + dutyUid: 'urn:lch:duty:test', + buyer: buyerSigner.identityKey, + endpoint: 'https://payee.example/lch', + satoshis: 9, + expiresAt: 200, + recoveryPeriodSeconds: 86_400 + }) + const demandId = await objectId('payment-demand', demand.body) + const lockingScript = new P2PKH().lock(publicKey.toAddress()) + const makeDelivery = async (version: number) => + new LCHBuyer(buyerSigner).createPaymentDelivery({ + demandId, + requestId, + atomicBeef: Uint8Array.from( + new Transaction(version, [], [{ satoshis: 9, lockingScript }]).toAtomicBEEF(true) + ), + outputIndex: 0, + derivationPrefix: demand.body.derivationPrefix as Uint8Array, + derivationSuffix: bytes(4, 32) + }) + await receiver.receive(demand, await makeDelivery(1)) + await expect(receiver.receive(demand, await makeDelivery(2))).rejects.toMatchObject({ + code: 'ERR_LCH_PAYMENT' + }) + }) +}) diff --git a/packages/content/lch/test/adapters.test.ts b/packages/content/lch/test/adapters.test.ts new file mode 100644 index 000000000..4796b19b7 --- /dev/null +++ b/packages/content/lch/test/adapters.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals' +import type { StorageDownloader, StorageUploader } from '@bsv/sdk' +import { + CHIRPContentSink, + IndexedDBLicenseStore, + MemoryLicenseStore, + UHRPContentSink, + UniversalContentSource, + fetchLCH, + type SignedObject +} from '../src/index.js' + +const publicResolver = async (): Promise => ['93.184.216.34'] + +describe('bounded content and endpoint adapters', () => { + it('revalidates redirects and strips credentials across content origins', async () => { + const connect = jest + .fn<(url: URL, init: RequestInit) => Promise>() + .mockImplementationOnce( + async () => + new Response(null, { + status: 302, + headers: { location: 'https://cdn.example/object' } + }) + ) + .mockImplementationOnce(async () => new Response(Uint8Array.of(1, 2, 3))) + const response = await fetchLCH( + 'https://origin.example/object', + { headers: { authorization: 'secret', 'x-bsv-payment': 'proof' } }, + 'content', + { resolve: publicResolver, connect } + ) + expect(response.ok).toBe(true) + const redirectedHeaders = new Headers(connect.mock.calls[1][1].headers) + expect(redirectedHeaders.has('authorization')).toBe(false) + expect(redirectedHeaders.has('x-bsv-payment')).toBe(false) + }) + + it('allows only method-preserving same-origin identity redirects', async () => { + const sameOrigin = jest + .fn<(url: URL, init: RequestInit) => Promise>() + .mockImplementationOnce( + async () => new Response(null, { status: 307, headers: { location: '/next' } }) + ) + .mockImplementationOnce(async () => new Response('ok')) + await expect( + fetchLCH('https://seller.example/start', { method: 'POST' }, 'identity', { + resolve: publicResolver, + connect: sameOrigin + }) + ).resolves.toBeInstanceOf(Response) + + await expect( + fetchLCH('https://seller.example/start', {}, 'identity', { + resolve: publicResolver, + connect: async () => + new Response(null, { + status: 308, + headers: { location: 'https://other.example/next' } + }) + }) + ).rejects.toMatchObject({ code: 'ERR_LCH_ENDPOINT' }) + }) + + it('uses CHIRP, UHRP, and bounded HTTPS sources', async () => { + const chirpDownload = jest.fn(async () => ({ data: Uint8Array.of(1, 2, 3) })) + const chirp = new UniversalContentSource({ + chirp: { download: chirpDownload }, + maximumBytes: 3 + }) + await expect(chirp.read('chirp://sha256.example', 1n, 2n)).resolves.toEqual( + Uint8Array.of(1, 2, 3) + ) + expect(chirpDownload).toHaveBeenCalledWith('chirp://sha256.example', { + range: { start: 1n, endExclusive: 2n } + }) + + const endpointPolicy = { + resolve: publicResolver, + connect: async (): Promise => new Response(Uint8Array.of(4, 5)) + } + const uhrp = { + resolve: jest.fn(async () => ['https://storage.example/object']) + } as unknown as StorageDownloader + const source = new UniversalContentSource({ uhrp, endpointPolicy, maximumBytes: 2 }) + await expect(source.read('uhrp://example')).resolves.toEqual(Uint8Array.of(4, 5)) + await expect(source.read('https://storage.example/object')).resolves.toEqual( + Uint8Array.of(4, 5) + ) + + const oversized = new UniversalContentSource({ + endpointPolicy: { + resolve: publicResolver, + connect: async () => new Response(Uint8Array.of(1, 2, 3)) + }, + maximumBytes: 2 + }) + await expect(oversized.read('https://storage.example/large')).rejects.toMatchObject({ + code: 'ERR_LCH_CONTENT_UNAVAILABLE' + }) + }) + + it('publishes through CHIRP and legacy UHRP sinks', async () => { + const publish = jest.fn(async () => ({ chirpURL: 'chirp://root' })) + const chirp = new CHIRPContentSink({ publish }, 86_400, 'audio/wav') + await expect(chirp.put(Uint8Array.of(1, 2))).resolves.toEqual(['chirp://root']) + expect(publish).toHaveBeenCalledWith({ + source: Uint8Array.of(1, 2), + retentionSeconds: 86_400, + logicalLength: 2, + mediaType: 'audio/wav' + }) + + const publishFile = jest.fn(async () => ({ uhrpURL: 'uhrp://hash' })) + const uhrp = new UHRPContentSink( + { publishFile } as unknown as StorageUploader, + 86_400, + 'audio/wav' + ) + await expect(uhrp.put(Uint8Array.of(3))).resolves.toEqual(['uhrp://hash']) + }) +}) + +describe('license stores', () => { + const originalIndexedDB = globalThis.indexedDB + + afterEach(() => { + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + value: originalIndexedDB + }) + }) + + it('stores and deletes in memory', async () => { + const store = new MemoryLicenseStore() + const license: SignedObject = { body: {}, signatures: [] } + const record = { assetId: 'asset', offerId: 'offer', license, storedAt: 1n } + await store.put(record) + await expect(store.get('asset')).resolves.toEqual(record) + await store.delete('asset', 'offer') + await expect(store.get('asset', 'offer')).resolves.toBeUndefined() + }) + + it('stores, lists, and deletes through IndexedDB', async () => { + const records = new Map() + const request = (result: unknown): IDBRequest => { + const value = {} as IDBRequest + queueMicrotask(() => { + Object.defineProperty(value, 'result', { configurable: true, value: result }) + value.onsuccess?.(new Event('success')) + }) + return value + } + const objectStore = { + put: (value: unknown, key: IDBValidKey) => { + records.set(String(key), value) + return request(key) + }, + delete: (key: IDBValidKey) => { + records.delete(String(key)) + return request(undefined) + }, + getAll: () => request([...records.values()]) + } as unknown as IDBObjectStore + const database = { + createObjectStore: () => objectStore, + transaction: () => + ({ + objectStore: () => objectStore + }) as IDBTransaction + } as unknown as IDBDatabase + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + value: { + open: () => { + const open = {} as IDBOpenDBRequest + queueMicrotask(() => { + Object.defineProperty(open, 'result', { configurable: true, value: database }) + open.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent) + open.onsuccess?.(new Event('success')) + }) + return open + } + } + }) + + const store = new IndexedDBLicenseStore('test-lch') + const record = { + assetId: 'asset', + offerId: 'offer', + license: { body: {}, signatures: [] }, + storedAt: 1n + } + await store.put(record) + await expect(store.get('asset')).resolves.toEqual(record) + await store.delete('asset', 'offer') + await expect(store.get('asset')).resolves.toBeUndefined() + }) +}) diff --git a/packages/content/lch/test/authority-policy.test.ts b/packages/content/lch/test/authority-policy.test.ts new file mode 100644 index 000000000..c0ef0f5ab --- /dev/null +++ b/packages/content/lch/test/authority-policy.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from '@jest/globals' +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { + PublicBRC77Verifier, + WalletBRC77Signer, + objectIri, + parsePinnedPolicy, + sha256, + signObject, + validateAuthorityChain, + type AuthorityBody, + type LCHValue +} from '../src/index.js' + +const bytes = (value: number, length: number): Uint8Array => new Uint8Array(length).fill(value) + +describe('authority and pinned policy validation', () => { + it('validates signatures, scope, and a fresh unspent observation', async () => { + const wallet = new ProtoWallet(new PrivateKey(1)) + const signer = await WalletBRC77Signer.create({ wallet, random: length => bytes(7, length) }) + const body: AuthorityBody = { + version: 1, + assetId: bytes(2, 32), + grantor: signer.identityKey, + grantee: bytes(3, 33), + interests: ['master'], + capabilities: ['issueOffer'], + notBefore: 1_000, + mayDelegate: false, + revocationOutpoint: `${'1'.repeat(64)}.0`, + revocationMaxAgeSeconds: 60, + nonce: bytes(4, 16) + } + const signed = await signObject( + 'authority', + body as unknown as Record, + signer + ) + await expect( + validateAuthorityChain( + [{ body, signatures: signed.signatures }], + { + controller: signer.identityKey, + actor: body.grantee, + assetId: body.assetId, + interest: 'master', + capability: 'issueOffer', + now: 1_100n, + network: 'mainnet' + }, + new PublicBRC77Verifier(), + { status: async () => ({ status: 'unspent', network: 'mainnet', observedAt: 1_050n }) } + ) + ).resolves.toBeUndefined() + }) + + it('fails closed for a reorganization-affected observation', async () => { + const wallet = new ProtoWallet(new PrivateKey(1)) + const signer = await WalletBRC77Signer.create({ wallet, random: length => bytes(8, length) }) + const body: AuthorityBody = { + version: 1, + assetId: bytes(2, 32), + grantor: signer.identityKey, + grantee: bytes(3, 33), + interests: ['master'], + capabilities: ['issueOffer'], + notBefore: 1, + mayDelegate: false, + revocationOutpoint: `${'1'.repeat(64)}.0`, + revocationMaxAgeSeconds: 60, + nonce: bytes(4, 16) + } + const signed = await signObject( + 'authority', + body as unknown as Record, + signer + ) + await expect( + validateAuthorityChain( + [{ body, signatures: signed.signatures }], + { + controller: signer.identityKey, + actor: body.grantee, + assetId: body.assetId, + interest: 'master', + capability: 'issueOffer', + now: 100n, + network: 'mainnet' + }, + new PublicBRC77Verifier(), + { + status: async () => ({ + status: 'unspent', + network: 'mainnet', + observedAt: 99n, + reorganizationAffected: true + }) + } + ) + ).rejects.toMatchObject({ code: 'ERR_LCH_REVOCATION' }) + }) + + it('rejects delegated scope and validity widening', async () => { + const rootSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(1)), + random: length => bytes(9, length) + }) + const delegateSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(2)), + random: length => bytes(10, length) + }) + const assetId = bytes(2, 32) + const root: AuthorityBody = { + version: 1, + assetId, + grantor: rootSigner.identityKey, + grantee: delegateSigner.identityKey, + interests: ['master'], + capabilities: ['issueOffer'], + usageProfiles: ['fixed'], + notBefore: 100, + notAfter: 200, + mayDelegate: true, + remainingDepth: 1, + nonce: bytes(11, 16) + } + const widened: AuthorityBody = { + ...root, + grantor: delegateSigner.identityKey, + grantee: bytes(12, 33), + usageProfiles: ['fixed', 'training'], + notAfter: 201, + mayDelegate: false, + nonce: bytes(13, 16) + } + const signedRoot = await signObject( + 'authority', + root as unknown as Record, + rootSigner + ) + const signedChild = await signObject( + 'authority', + widened as unknown as Record, + delegateSigner + ) + await expect( + validateAuthorityChain( + [ + { body: root, signatures: signedRoot.signatures }, + { body: widened, signatures: signedChild.signatures } + ], + { + controller: rootSigner.identityKey, + actor: widened.grantee, + assetId, + interest: 'master', + capability: 'issueOffer', + usageProfile: 'fixed', + now: 150n, + network: 'mainnet' + }, + new PublicBRC77Verifier() + ) + ).rejects.toMatchObject({ code: 'ERR_LCH_AUTHORITY' }) + }) + + it('requires delegated intervals and remaining depth to narrow', async () => { + const rootSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(21)), + random: length => bytes(21, length) + }) + const delegateSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(22)), + random: length => bytes(22, length) + }) + const assetId = bytes(23, 32) + const root: AuthorityBody = { + version: 1, + assetId, + grantor: rootSigner.identityKey, + grantee: delegateSigner.identityKey, + interests: ['master'], + capabilities: ['issueOffer'], + notBefore: 100, + notAfter: 200, + mayDelegate: true, + remainingDepth: 1, + nonce: bytes(24, 16) + } + const child: AuthorityBody = { + ...root, + grantor: delegateSigner.identityKey, + grantee: bytes(25, 33), + notBefore: 101, + notAfter: 199, + remainingDepth: 0, + nonce: bytes(26, 16) + } + const signedRoot = await signObject( + 'authority', + root as unknown as Record, + rootSigner + ) + const validate = async (candidate: AuthorityBody): Promise => { + const signedChild = await signObject( + 'authority', + candidate as unknown as Record, + delegateSigner + ) + await validateAuthorityChain( + [ + { body: root, signatures: signedRoot.signatures }, + { body: candidate, signatures: signedChild.signatures } + ], + { + controller: rootSigner.identityKey, + actor: candidate.grantee, + assetId, + interest: 'master', + capability: 'issueOffer', + now: 150n, + network: 'mainnet' + }, + new PublicBRC77Verifier() + ) + } + + await expect(validate(child)).resolves.toBeUndefined() + await expect(validate({ ...child, notBefore: 99 })).rejects.toMatchObject({ + code: 'ERR_LCH_AUTHORITY' + }) + const withoutEnd = { ...child } + delete withoutEnd.notAfter + await expect(validate(withoutEnd)).rejects.toMatchObject({ + code: 'ERR_LCH_AUTHORITY' + }) + await expect(validate({ ...child, notAfter: 201 })).rejects.toMatchObject({ + code: 'ERR_LCH_AUTHORITY' + }) + const withoutDepth = { ...child } + delete withoutDepth.remainingDepth + await expect(validate(withoutDepth)).rejects.toMatchObject({ + code: 'ERR_LCH_AUTHORITY' + }) + await expect(validate({ ...child, remainingDepth: 1 })).rejects.toMatchObject({ + code: 'ERR_LCH_AUTHORITY' + }) + }) + + it('virtualizes only the top-level policy uid', async () => { + const policy = { + '@context': ['http://www.w3.org/ns/odrl.jsonld'], + '@type': 'Offer', + uid: 'lch:offer:self', + profile: 'https://bsv.brc.dev/apps/0170#odrl-profile', + permission: [{ target: 'lch:offer:self', action: 'play' }] + } + const inline = new TextEncoder().encode(JSON.stringify(policy)) + const iri = await objectIri('offer', { version: 1 }) + const result = await parsePinnedPolicy( + { mediaType: 'application/ld+json', digest: await sha256(inline), inline }, + 'Offer', + iri + ) + expect(result.policy.uid).toBe(iri) + expect(result.permissions[0].target).toBe('lch:offer:self') + }) + + it('rejects unsupported remote JSON-LD contexts and permissive conflict handling', async () => { + const policy = { + '@context': ['http://www.w3.org/ns/odrl.jsonld', 'https://untrusted.example/context.jsonld'], + '@type': 'Offer', + uid: 'lch:offer:self', + profile: 'https://bsv.brc.dev/apps/0170#odrl-profile', + conflict: 'perm' + } + const inline = new TextEncoder().encode(JSON.stringify(policy)) + await expect( + parsePinnedPolicy( + { + mediaType: 'application/ld+json', + digest: await sha256(inline), + inline + }, + 'Offer', + 'lch:offer:sha256:test' + ) + ).rejects.toMatchObject({ code: 'ERR_LCH_POLICY' }) + }) +}) diff --git a/packages/content/lch/test/cbor.property.test.ts b/packages/content/lch/test/cbor.property.test.ts new file mode 100644 index 000000000..0fa553afd --- /dev/null +++ b/packages/content/lch/test/cbor.property.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from '@jest/globals' +import fc from 'fast-check' +import { decodeDeterministicCbor, encodeDeterministicCbor } from '../src/index.js' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH + +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) + ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) + : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +describe('deterministic CBOR properties', () => { + it('round trips supported values canonically', () => { + const leaf = fc.oneof( + fc.boolean(), + fc.constant(null), + fc.nat(), + fc.string().map(value => value.normalize('NFC')), + fc.uint8Array() + ) + const values = fc.oneof( + leaf, + fc.array(leaf, { maxLength: 20 }), + fc.dictionary( + fc.string({ minLength: 1 }).map(value => value.normalize('NFC')), + leaf + ) + ) + fc.assert( + fc.property(values, value => { + const first = encodeDeterministicCbor(value) + const second = encodeDeterministicCbor(decodeDeterministicCbor(first)) + expect(second).toEqual(first) + }) + ) + }) +}) diff --git a/packages/content/lch/test/cbor.test.ts b/packages/content/lch/test/cbor.test.ts new file mode 100644 index 000000000..412b285e9 --- /dev/null +++ b/packages/content/lch/test/cbor.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from '@jest/globals' +import { decodeDeterministicCbor, encodeDeterministicCbor, LCHError } from '../src/index.js' + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') +} + +function expectCborError(bytes: Uint8Array, message: string): void { + try { + decodeDeterministicCbor(bytes) + throw new Error('Expected CBOR decoding to fail') + } catch (error) { + expect(error).toBeInstanceOf(LCHError) + expect(error).toMatchObject({ code: 'ERR_LCH_CBOR', message }) + } +} + +describe('deterministic LCH CBOR', () => { + it('orders keys by encoded bytes and round trips uint64 values', () => { + const value = { longer: 0x20_0000_0000_0000n, a: Uint8Array.of(1, 2), z: [true, null, 'é'] } + const encoded = encodeDeterministicCbor(value) + expect(Array.from(encoded.slice(0, 4))).toEqual([0xa3, 0x61, 0x61, 0x42]) + expect(decodeDeterministicCbor(encoded)).toEqual(value) + }) + + it.each([ + [0n, '00'], + [23n, '17'], + [24n, '1818'], + [255n, '18ff'], + [256n, '190100'], + [65_535n, '19ffff'], + [65_536n, '1a00010000'], + [0xffff_ffffn, '1affffffff'], + [0x1_0000_0000n, '1b0000000100000000'], + [0xffff_ffff_ffff_ffffn, '1bffffffffffffffff'] + ])('encodes uint boundary %s with its shortest head', (value, expected) => { + const encoded = encodeDeterministicCbor(value) + expect(hex(encoded)).toBe(expected) + expect(decodeDeterministicCbor(encoded)).toBe( + value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value + ) + }) + + it('sorts map keys by their complete encoded bytes, including different lengths', () => { + const encoded = encodeDeterministicCbor({ aa: 2, b: 1, a: 0 }) + expect(hex(encoded)).toBe('a361610061620162616102') + expect(decodeDeterministicCbor(encoded)).toEqual( + Object.assign(Object.create(null) as Record, { a: 0, b: 1, aa: 2 }) + ) + }) + + it.each([ + [Uint8Array.of(0x18, 0x17), 'Non-shortest CBOR length'], + [Uint8Array.of(0x19, 0x00, 0xff), 'Non-shortest CBOR length'], + [Uint8Array.of(0x1a, 0x00, 0x00, 0xff, 0xff), 'Non-shortest CBOR length'], + [ + Uint8Array.of(0x1b, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff), + 'Non-shortest CBOR length' + ], + [Uint8Array.of(0x9f, 0xff), 'Indefinite-length or reserved CBOR item'], + [ + Uint8Array.of(0xa2, 0x61, 0x61, 0x00, 0x61, 0x61, 0x01), + 'CBOR map keys are duplicated or unordered' + ], + [ + Uint8Array.of(0xa2, 0x61, 0x62, 0x00, 0x61, 0x61, 0x01), + 'CBOR map keys are duplicated or unordered' + ], + [Uint8Array.of(0xa1, 0x00, 0x00), 'LCH CBOR map keys must be text'], + [Uint8Array.of(0xc0, 0x00), 'Unsupported CBOR major type 6'], + [Uint8Array.of(0xf9, 0x00, 0x00), 'Unsupported CBOR simple or floating-point value'], + [Uint8Array.of(0x61, 0xff), 'CBOR text is not valid UTF-8'], + [Uint8Array.of(0x00, 0x00), 'Trailing bytes after CBOR value'] + ])('rejects non-canonical input with its stable protocol error', (bytes, message) => { + expectCborError(bytes, message) + }) + + it('rejects uints outside the BRC-170 data model', () => { + expect(() => encodeDeterministicCbor(-1n)).toThrow( + expect.objectContaining({ code: 'ERR_LCH_CBOR', message: 'CBOR uint exceeds uint64' }) + ) + expect(() => encodeDeterministicCbor(0x1_0000_0000_0000_0000n)).toThrow( + expect.objectContaining({ code: 'ERR_LCH_CBOR', message: 'CBOR uint exceeds uint64' }) + ) + }) + + it('rejects undefined map members before signing or hashing', () => { + expect(() => + encodeDeterministicCbor({ missing: undefined } as unknown as Record) + ).toThrow( + expect.objectContaining({ + code: 'ERR_LCH_CBOR', + message: 'Undefined CBOR map value: missing' + }) + ) + }) +}) diff --git a/packages/content/lch/test/cli.test.ts b/packages/content/lch/test/cli.test.ts new file mode 100644 index 000000000..d61a1a901 --- /dev/null +++ b/packages/content/lch/test/cli.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from '@jest/globals' +import { encodeDeterministicCbor, frameLCH } from '../src/index.js' +import { runLCHCLI, type LCHCLIRuntime } from '../src/cli.js' + +function runtime( + args: string[], + bytes = new Uint8Array() +): { + runtime: LCHCLIRuntime + output: string[] +} { + const output: string[] = [] + return { + output, + runtime: { + args, + read: async () => bytes, + write: message => output.push(message) + } + } +} + +describe('LCH CLI', () => { + it('shows help and verifies or inspects framing', async () => { + const help = runtime(['--help']) + await runLCHCLI(help.runtime) + expect(help.output.join('')).toContain('Usage: lch') + + const file = frameLCH( + { + lch: 1, + asset: { large: 0x20_0000_0000_0000n, digest: Uint8Array.of(1) }, + acquisition: [{}], + signatures: [Uint8Array.of(2)] + }, + Uint8Array.of(3) + ) + const verify = runtime(['verify', 'demo.lch'], file) + await runLCHCLI(verify.runtime) + expect(verify.output.join('')).toContain('ciphertext=1') + + const inspect = runtime(['inspect', 'demo.lch'], file) + await runLCHCLI(inspect.runtime) + expect(inspect.output.join('')).toContain('$bytes') + expect(inspect.output.join('')).toContain('$uint') + }) + + it('computes object IDs and rejects malformed invocation', async () => { + const cbor = encodeDeterministicCbor({ version: 1 }) + const id = runtime(['id', 'offer', 'offer.cbor'], cbor) + await runLCHCLI(id.runtime) + expect(id.output[0]).toMatch(/^lch:offer:sha256:/u) + await expect(runLCHCLI(runtime(['verify']).runtime)).rejects.toThrow('A file path is required') + await expect(runLCHCLI(runtime(['wat']).runtime)).rejects.toThrow('Unknown command') + }) +}) diff --git a/packages/content/lch/test/composition-payment.test.ts b/packages/content/lch/test/composition-payment.test.ts new file mode 100644 index 000000000..23c398796 --- /dev/null +++ b/packages/content/lch/test/composition-payment.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from '@jest/globals' +import { + LCHComposer, + LCH_MECHANISMS, + StaticC2PAAdapter, + matchFinalizedOutputs, + recoveryUntil, + sha256, + validateC2PAComposition, + validateIngredient, + walkComposition +} from '../src/index.js' + +const id = (value: number): Uint8Array => new Uint8Array(32).fill(value) + +describe('composition and payment invariants', () => { + it('walks an empty composition without inventing source obligations', async () => { + await expect( + walkComposition({ assetId: id(8), selection: { type: 'all' } }, async () => undefined) + ).resolves.toEqual([]) + }) + + it('builds only the pinned whole-placement mapping', () => { + const record = new LCHComposer(id(9)) + .addWholePlacement({ + sourceAssetId: id(1), + sourceLicenseId: id(2), + c2paIngredient: { + url: 'self#jumbf=/c2pa/source/c2pa.assertions/c2pa.ingredient.v3', + alg: 'sha256', + hash: id(3) + }, + relationship: 'componentOf', + sourceSelection: { type: 'segments', ranges: [[2, 4]] } + }) + .build() + expect(record.ingredients[0].mappingProfile).toBe(LCH_MECHANISMS.wholePlacement) + expect(() => + validateIngredient({ + ...record.ingredients[0], + mappingProfile: 'https://app.example/trim-v1' + }) + ).toThrow() + }) + + it('matches demand outputs after wallet reordering', () => { + const scriptA = Uint8Array.of(1) + const scriptB = Uint8Array.of(2) + const result = matchFinalizedOutputs( + [ + { demandId: id(1), satoshis: 7n, lockingScript: scriptA }, + { demandId: id(2), satoshis: 5n, lockingScript: scriptB } + ], + [ + { satoshis: 100n, lockingScript: Uint8Array.of(3) }, + { satoshis: 5n, lockingScript: scriptB }, + { satoshis: 7n, lockingScript: scriptA } + ] + ) + expect([...result.values()]).toEqual([2, 1]) + }) + + it('rejects ambiguous matches and computes recovery deadlines', () => { + expect(() => + matchFinalizedOutputs([{ demandId: id(1), satoshis: 1n, lockingScript: id(2) }], []) + ).toThrow() + expect(recoveryUntil(1_000n, 86_400n)).toBe(87_400n) + }) + + it('rejects composition cycles even when the repeated Asset uses another selection', async () => { + const ingredient = (sourceAssetId: Uint8Array, value: number) => ({ + sourceAssetId, + sourceLicenseId: id(value), + c2paIngredient: { + url: `self#jumbf=/c2pa/${value}`, + alg: 'sha256', + hash: id(value + 1) + }, + relationship: 'componentOf' as const, + sourceSelection: { type: 'segments' as const, ranges: [[value, value + 1]] }, + derivedSelection: { type: 'all' as const }, + mappingProfile: LCH_MECHANISMS.wholePlacement + }) + const assetA = id(10) + const assetB = id(11) + const recordA = { + version: 1 as const, + c2paManifestDigest: id(12), + ingredients: [ingredient(assetB, 1)] + } + const recordB = { + version: 1 as const, + c2paManifestDigest: id(13), + ingredients: [ingredient(assetA, 2)] + } + await expect( + walkComposition( + { assetId: assetA, selection: { type: 'all' }, record: recordA }, + async sourceAssetId => ({ + assetId: sourceAssetId, + selection: { type: 'all' }, + record: sourceAssetId[0] === assetA[0] ? recordA : recordB + }) + ) + ).rejects.toMatchObject({ code: 'ERR_LCH_CYCLE' }) + }) + + it('binds the exact C2PA hashed URI and manifest digest', async () => { + const manifest = new TextEncoder().encode('detached c2pa manifest') + const hashedUri = { + url: 'self#jumbf=/c2pa/source/c2pa.assertions/c2pa.ingredient.v3', + alg: 'sha256', + hash: id(21) + } + const record = new LCHComposer(await sha256(manifest)) + .addWholePlacement({ + sourceAssetId: id(20), + sourceLicenseId: id(22), + c2paIngredient: hashedUri, + relationship: 'componentOf', + sourceSelection: { type: 'all' } + }) + .build() + const adapter = new StaticC2PAAdapter([ + { sourceAssetId: id(20), relationship: 'componentOf', hashedUri } + ]) + await expect( + validateC2PAComposition(id(23), manifest, record, adapter) + ).resolves.toBeUndefined() + await expect( + validateC2PAComposition(id(23), new Uint8Array(manifest.length), record, adapter) + ).rejects.toMatchObject({ code: 'ERR_LCH_PROVENANCE' }) + }) + + it('keeps editorial transforms as non-normative metadata on distinct whole placements', () => { + const composer = new LCHComposer(id(30)) + const transforms = [ + { kind: 'identity' }, + { kind: 'time-warp', rate: { numerator: 1, denominator: 2 } }, + { kind: 'time-warp', rate: { numerator: 2, denominator: 1 } }, + { kind: 'reverse' }, + { kind: 'distortion', amount: 4 } + ] + transforms.forEach((transform, index) => { + composer.addWholePlacement({ + sourceAssetId: id(31), + sourceLicenseId: id(32), + c2paIngredient: { + url: `self#jumbf=/c2pa/edit-${index}`, + alg: 'sha256', + hash: id(40 + index) + }, + relationship: 'componentOf', + sourceSelection: { type: 'segments', ranges: [[1, 3]] }, + metadata: { + 'https://example.invalid/lch-reference/edit-v1': transform + } + }) + }) + const record = composer.build() + expect(record.ingredients).toHaveLength(transforms.length) + expect( + record.ingredients.every(item => item.mappingProfile === LCH_MECHANISMS.wholePlacement) + ).toBe(true) + expect(record.ingredients.every(item => item.derivedSelection.type === 'all')).toBe(true) + }) + + it('rejects two placements that point at the same C2PA assertion', () => { + const placement = { + sourceAssetId: id(50), + sourceLicenseId: id(51), + c2paIngredient: { + url: 'self#jumbf=/c2pa/reused', + alg: 'sha256', + hash: id(52) + }, + relationship: 'componentOf' as const, + sourceSelection: { type: 'all' as const } + } + expect(() => + new LCHComposer(id(53)).addWholePlacement(placement).addWholePlacement(placement).build() + ).toThrow() + }) +}) diff --git a/packages/content/lch/test/core.test.ts b/packages/content/lch/test/core.test.ts new file mode 100644 index 000000000..19d63f25c --- /dev/null +++ b/packages/content/lch/test/core.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from '@jest/globals' +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { + LCHPublisher, + LCHReader, + MemoryContentSink, + WalletBRC77Signer, + frameLCH, + objectId, + parseLCH, + toHex +} from '../src/index.js' + +describe('publisher and reader reference flow', () => { + it('publishes detached verified content and decrypts it', async () => { + const signer = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(1)), + random: length => new Uint8Array(length).fill(7) + }) + const storage = new MemoryContentSink() + const publisher = new LCHPublisher(signer) + const plaintext = new TextEncoder().encode('reference implementation') + const protectedAsset = await publisher.protect(plaintext, { + mediaType: 'text/plain', + name: 'reference.txt', + rights: [ + { interest: 'text', holder: { name: 'Ty Everett' }, controller: signer.identityKey } + ], + sink: storage, + segmentSize: 8, + random: length => new Uint8Array(length).fill(length) + }) + const published = await publisher.publish( + protectedAsset, + [ + { + mode: 'discover', + usageProfile: 'test', + seller: signer.identityKey, + endpoint: 'https://example.com/lch' + } + ], + false + ) + const reader = new LCHReader(storage) + const inspected = await reader.inspect(published.bytes) + expect(toHex(inspected.assetId)).toBe(toHex(await objectId('asset', protectedAsset.asset))) + expect(await reader.decrypt(inspected, protectedAsset.keys)).toEqual(plaintext) + }) + + it('rejects framing with trailing invalid header data', () => { + const bytes = frameLCH({ lch: 1, asset: {}, acquisition: [], signatures: [] }) + const parsed = parseLCH(bytes) + expect(parsed.header.lch).toBe(1) + }) + + it('rejects an unauthorized header mutation and a signed wrong plaintext digest', async () => { + const signer = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(2)), + random: length => new Uint8Array(length).fill(8) + }) + const storage = new MemoryContentSink() + const publisher = new LCHPublisher(signer) + const protectedAsset = await publisher.protect(new TextEncoder().encode('authentic'), { + mediaType: 'text/plain', + name: 'authentic.txt', + rights: [ + { + interest: 'text', + holder: { name: 'Creator' }, + controller: signer.identityKey + } + ], + sink: storage, + random: length => new Uint8Array(length).fill(length + 1) + }) + const published = await publisher.publish( + protectedAsset, + [ + { + mode: 'discover', + usageProfile: 'test', + seller: signer.identityKey, + endpoint: 'https://example.com/lch' + } + ], + false + ) + const parsed = parseLCH(published.bytes) + const tamperedAsset = { + ...(parsed.header.asset as Record), + name: 'tampered.txt' + } + const reader = new LCHReader(storage) + await expect( + reader.inspect(frameLCH({ ...parsed.header, asset: tamperedAsset })) + ).rejects.toMatchObject({ code: 'ERR_LCH_AUTHORITY' }) + + const representation = protectedAsset.asset.representation as Record + representation.plaintextDigest = new Uint8Array(32) + const wrongDigest = await publisher.publish( + protectedAsset, + [ + { + mode: 'discover', + usageProfile: 'test', + seller: signer.identityKey, + endpoint: 'https://example.com/lch' + } + ], + false + ) + const inspected = await reader.inspect(wrongDigest.bytes) + await expect(reader.decrypt(inspected, protectedAsset.keys)).rejects.toMatchObject({ + code: 'ERR_LCH_CONTENT_DIGEST' + }) + }) +}) diff --git a/packages/content/lch/test/encryption.test.ts b/packages/content/lch/test/encryption.test.ts new file mode 100644 index 000000000..8ca4da32a --- /dev/null +++ b/packages/content/lch/test/encryption.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from '@jest/globals' +import { + decryptSegmented, + encryptSegmented, + LCHError, + recordRange, + validateKeyGrantsForSelection +} from '../src/index.js' + +function deterministicRandom(): (length: number) => Uint8Array { + let call = 0 + return length => { + call += 1 + return Uint8Array.from({ length }, (_, index) => (index * 17 + length + call) & 0xff) + } +} + +describe('segmented AES-GCM', () => { + it('decrypts all records and selected key periods', async () => { + const plaintext = new TextEncoder().encode('0123456789abcdefghijklmnopqrstuvwxyz') + const encrypted = await encryptSegmented(plaintext, { + segmentSize: 8, + keyPeriodSegments: 2, + random: deterministicRandom() + }) + expect( + await decryptSegmented(encrypted.ciphertext, encrypted.descriptor, encrypted.keys) + ).toEqual(plaintext) + const firstPeriod = new Map([...encrypted.keys].slice(0, 1)) + expect( + new TextDecoder().decode( + await decryptSegmented(encrypted.ciphertext, encrypted.descriptor, firstPeriod, { + type: 'segments', + ranges: [[0, 2]] + }) + ) + ).toBe('0123456789abcdef') + expect(() => recordRange(encrypted.descriptor, 5)).toThrow(LCHError) + const grants = encrypted.descriptor.keyPeriods.map(period => ({ keyId: period.keyId })) + expect(() => + validateKeyGrantsForSelection(encrypted.descriptor, { type: 'all' }, grants.slice(0, 1)) + ).toThrow(LCHError) + expect(() => + validateKeyGrantsForSelection(encrypted.descriptor, { type: 'all' }, grants) + ).not.toThrow() + }) + + it('authenticates before releasing plaintext', async () => { + const encrypted = await encryptSegmented(Uint8Array.of(1, 2, 3), { + segmentSize: 2, + random: deterministicRandom() + }) + encrypted.ciphertext[0] ^= 1 + await expect( + decryptSegmented(encrypted.ciphertext, encrypted.descriptor, encrypted.keys) + ).rejects.toMatchObject({ code: 'ERR_LCH_AUTHENTICATION' }) + }) + + it('represents empty content as one authentication tag', async () => { + const encrypted = await encryptSegmented(new Uint8Array(), { random: deterministicRandom() }) + expect(encrypted.ciphertext).toHaveLength(16) + expect( + await decryptSegmented(encrypted.ciphertext, encrypted.descriptor, encrypted.keys) + ).toHaveLength(0) + }) + + it('rejects CEK reuse across key periods', async () => { + await expect( + encryptSegmented(new Uint8Array(8), { + segmentSize: 2, + keyPeriodSegments: 1, + random: length => new Uint8Array(length).fill(7) + }) + ).rejects.toMatchObject({ code: 'ERR_LCH_KEY' }) + }) + + it('uses secure randomness by default and rejects a descriptor with an uncovered segment', async () => { + const encrypted = await encryptSegmented(Uint8Array.of(1, 2, 3), { segmentSize: 2 }) + await expect( + decryptSegmented( + encrypted.ciphertext, + { ...encrypted.descriptor, keyPeriods: [] }, + encrypted.keys + ) + ).rejects.toMatchObject({ code: 'ERR_LCH_KEY' }) + }) +}) diff --git a/packages/content/lch/test/http.test.ts b/packages/content/lch/test/http.test.ts new file mode 100644 index 000000000..445fe5892 --- /dev/null +++ b/packages/content/lch/test/http.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { + LCHError, + LCHHttpAcquisitionClient, + LCHHttpServer, + WalletBRC77Signer, + encodeDeterministicCbor, + signObject, + type PaymentCompletion +} from '../src/index.js' + +const bytes = (value: number, length: number): Uint8Array => new Uint8Array(length).fill(value) + +describe('LCH HTTP binding', () => { + it('answers CORS preflight and rejects wrong methods and unregistered message types', async () => { + const endpoint = 'https://lch.test/acquisition' + const server = new LCHHttpServer({ handlers: {}, allowOrigin: 'https://player.test' }) + const options = await server.handle(new Request(endpoint, { method: 'OPTIONS' })) + expect(options.status).toBe(204) + expect(options.headers.get('access-control-allow-origin')).toBe('https://player.test') + await expect(server.handle(new Request(endpoint))).resolves.toMatchObject({ status: 405 }) + const unsupported = await server.handle( + new Request(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/vnd.bsv.lch+cbor; type=future-transport' + }, + body: encodeDeterministicCbor(null).slice().buffer + }) + ) + expect(unsupported.status).toBe(415) + }) + + it('routes every acquisition message as bounded deterministic CBOR', async () => { + const signer = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(71)) + }) + const request = await signObject( + 'license-request', + { version: 1, buyer: signer.identityKey }, + signer + ) + const quote = await signObject('quote', { version: 1 }, signer) + const demand = await signObject( + 'payment-demand', + { version: 1, payee: signer.identityKey }, + signer + ) + const readiness = await signObject( + 'payment-readiness', + { version: 1, payee: signer.identityKey }, + signer + ) + const authorization = await signObject( + 'payment-authorization', + { version: 1, payee: signer.identityKey }, + signer + ) + const delivery = await signObject( + 'payment-delivery', + { version: 1, buyer: signer.identityKey }, + signer + ) + const receipt = await signObject( + 'payment-receipt', + { version: 1, payee: signer.identityKey }, + signer + ) + const evidence = await signObject( + 'transaction-evidence', + { version: 1, provider: signer.identityKey }, + signer + ) + const acknowledgement = await signObject( + 'payment-delivery-ack', + { version: 1, provider: signer.identityKey }, + signer + ) + const retrieval = await signObject( + 'payment-delivery-retrieval', + { version: 1, payee: signer.identityKey }, + signer + ) + const license = await signObject('license', { version: 1, issuer: signer.identityKey }, signer) + const preflightLicense = jest.fn(async () => undefined) + const preflightDemand = jest.fn(async () => readiness) + const quoteHandler = jest.fn(async () => quote) + const paymentDelivery = jest.fn(async () => receipt) + const authorizePayment = jest.fn(async () => authorization) + const storeDelivery = jest.fn(async () => acknowledgement) + const attestTransaction = jest.fn(async () => evidence) + const retrieveDelivery = jest.fn(async () => ({ + authorization, + delivery, + deliveryAcknowledgement: acknowledgement + })) + const complete = jest.fn(async () => license) + const recover = jest.fn(async () => license) + const server = new LCHHttpServer({ + handlers: { + preflightLicense, + quote: quoteHandler, + preflightDemand, + authorizePayment, + paymentDelivery, + storeDelivery, + attestTransaction, + retrieveDelivery, + complete, + recover + } + }) + const endpoint = 'https://lch.test/acquisition' + const client = new LCHHttpAcquisitionClient({ + endpointPolicy: { + allowLocalOrigins: ['https://lch.test'], + connect: async (url, init) => server.handle(new Request(url, init)) + } + }) + await client.preflightLicense(endpoint, request) + await expect(client.quote(endpoint, request)).resolves.toEqual(quote) + await expect(client.preflightDemand(endpoint, demand)).resolves.toEqual(readiness) + await expect(client.authorizePayment(endpoint, demand)).resolves.toEqual(authorization) + await expect(client.deliver(endpoint, delivery)).resolves.toEqual(receipt) + await expect(client.storeDelivery(endpoint, authorization, delivery)).resolves.toEqual( + acknowledgement + ) + await expect(client.attestTransaction(endpoint, authorization, bytes(9, 4))).resolves.toEqual( + evidence + ) + await expect(client.retrieveDelivery(endpoint, retrieval)).resolves.toEqual({ + authorization, + delivery, + deliveryAcknowledgement: acknowledgement + }) + const completion: PaymentCompletion = { + request, + quote, + atomicBeef: bytes(1, 4), + receipts: [receipt] + } + await expect(client.complete(endpoint, completion)).resolves.toEqual(license) + await expect(client.recover(endpoint, bytes(2, 32))).resolves.toEqual(license) + expect(preflightLicense).toHaveBeenCalledWith(request) + expect(quoteHandler).toHaveBeenCalledWith(request) + expect(preflightDemand).toHaveBeenCalledWith(demand) + expect(paymentDelivery).toHaveBeenCalledWith(delivery) + expect(authorizePayment).toHaveBeenCalledWith(demand) + expect(storeDelivery).toHaveBeenCalledWith({ authorization, delivery }) + expect(attestTransaction).toHaveBeenCalledWith({ authorization, atomicBeef: bytes(9, 4) }) + expect(retrieveDelivery).toHaveBeenCalledWith(retrieval) + expect(complete.mock.calls[0]?.[0]).toEqual(completion) + }) + + it('returns undefined for an unknown recovery and rejects unsupported media types', async () => { + const server = new LCHHttpServer({ handlers: { recover: async () => undefined } }) + const endpoint = 'https://lch.test/acquisition' + const client = new LCHHttpAcquisitionClient({ + endpointPolicy: { + allowLocalOrigins: ['https://lch.test'], + connect: async (url, init) => server.handle(new Request(url, init)) + } + }) + await expect(client.recover(endpoint, bytes(3, 32))).resolves.toBeUndefined() + const response = await server.handle( + new Request(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }) + ) + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe('application/vnd.bsv.lch+cbor; type=error') + }) + + it('propagates a bounded stable LCH error envelope instead of reducing it to HTTP status', async () => { + const server = new LCHHttpServer({ + handlers: { + recover: async () => { + throw new LCHError('ERR_LCH_AUTHORITY', 'issuer authority is unavailable') + } + } + }) + const client = new LCHHttpAcquisitionClient({ + endpointPolicy: { + allowLocalOrigins: ['https://lch.test'], + connect: async (url, init) => server.handle(new Request(url, init)) + } + }) + await expect( + client.recover('https://lch.test/acquisition', bytes(7, 32)) + ).rejects.toMatchObject({ code: 'ERR_LCH_AUTHORITY' }) + }) + + it('enforces request body bounds while streaming', async () => { + const server = new LCHHttpServer({ handlers: {}, maximumRequestBytes: 4 }) + const response = await server.handle( + new Request('https://lch.test/acquisition', { + method: 'POST', + headers: { 'content-type': 'application/vnd.bsv.lch+cbor; type=license-request' }, + body: bytes(1, 5).slice().buffer + }) + ) + expect(response.status).toBe(422) + }) +}) diff --git a/packages/content/lch/test/key-delivery.test.ts b/packages/content/lch/test/key-delivery.test.ts new file mode 100644 index 000000000..dbfaf4d68 --- /dev/null +++ b/packages/content/lch/test/key-delivery.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from '@jest/globals' +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { WalletBRC78KeyDelivery, keyIdFor } from '../src/index.js' + +describe('BRC-78 CEK delivery', () => { + it('binds the recipient and the LCH Key ID', async () => { + const senderWallet = new ProtoWallet(new PrivateKey(1)) + const recipientWallet = new ProtoWallet(new PrivateKey(2)) + const recipient = (await recipientWallet.getPublicKey({ identityKey: true })).publicKey + const cek = new Uint8Array(32).fill(9) + const keyId = await keyIdFor(cek) + const sender = new WalletBRC78KeyDelivery(senderWallet) + const receiver = new WalletBRC78KeyDelivery(recipientWallet) + const payload = await sender.deliver(recipient, keyId, cek) + expect(payload.slice(0, 4)).toEqual(Uint8Array.of(0x42, 0x42, 0x10, 0x33)) + await expect(receiver.recover(payload)).resolves.toEqual({ keyId, cek }) + const tampered = payload.slice() + tampered[tampered.length - 1] ^= 1 + await expect(receiver.recover(tampered)).rejects.toMatchObject({ code: 'ERR_LCH_KEY' }) + }) +}) diff --git a/packages/content/lch/test/multipay-client.test.ts b/packages/content/lch/test/multipay-client.test.ts new file mode 100644 index 000000000..cd0d94f19 --- /dev/null +++ b/packages/content/lch/test/multipay-client.test.ts @@ -0,0 +1,525 @@ +import { describe, expect, it } from '@jest/globals' +import { + LockingScript, + PrivateKey, + ProtoWallet, + Transaction, + type AtomicBEEF, + type CreateActionArgs, + type WalletInterface +} from '@bsv/sdk' +import { + LCHHttpServer, + LCHHttpAcquisitionClient, + LCHBuyer, + LCHMultipayBuyer, + LCHPayee, + LCHQuoteIssuer, + LCHSettlementService, + LCH_SETTLEMENT_PROFILES, + LCH_TRANSACTION_EVIDENCE_POLICIES, + WalletAuthorizedOutputPayee, + WalletBRC77Signer, + objectId, + signObject, + toHex, + validateLicenseRequest, + type PaymentCompletion, + type LCHAcquisitionTransport, + type SignedObject +} from '../src/index.js' + +const bytes = (value: number, length: number): Uint8Array => new Uint8Array(length).fill(value) + +describe('recovery-safe multipay buyer', () => { + it('preflights, funds once, delivers every output, completes, and recovers the License', async () => { + const buyerWallet = actionWallet(121) + const issuerSigner = await walletSigner(122) + const payees = await Promise.all( + [ + { + key: 123, + satoshis: 7, + dutyUid: 'urn:lch:duty:recording', + endpoint: 'https://drummer.test/payments' + }, + { + key: 124, + satoshis: 5, + dutyUid: 'urn:lch:duty:composition', + endpoint: 'https://composer.test/payments' + } + ].map(async item => ({ + ...item, + signer: await walletSigner(item.key) + })) + ) + const endpoint = 'https://issuer.test/licenses' + const offerId = bytes(1, 32) + const assetId = bytes(2, 32) + const demands = new Map() + let issuedLicense: SignedObject | undefined + const server = new LCHHttpServer({ + handlers: { + preflightLicense: async request => { + await validateLicenseRequest(request) + }, + quote: async request => { + const requestId = await validateLicenseRequest(request) + const buyerIdentity = request.body.buyer as Uint8Array + const signedDemands = await Promise.all( + payees.map(async payee => { + const demand = await new LCHPayee(payee.signer).createDemand({ + requestId, + offerId, + dutyUid: payee.dutyUid, + buyer: buyerIdentity, + endpoint: payee.endpoint, + satoshis: payee.satoshis, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400 + }) + demands.set(toHex(await objectId('payment-demand', demand.body)), { demand, payee }) + return demand + }) + ) + return new LCHQuoteIssuer(issuerSigner).createQuote({ + requestId, + offerId, + assetId, + buyer: buyerIdentity, + selection: { type: 'all' }, + demands: signedDemands, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400 + }) + }, + complete: async (completion: PaymentCompletion) => { + issuedLicense = await signObject( + 'license', + { + version: 1, + requestId: await objectId('license-request', completion.request.body), + subject: completion.request.body.buyer! + }, + issuerSigner + ) + return issuedLicense + }, + recover: async () => issuedLicense + } + }) + const payeeServers = new Map( + payees.map( + payee => + [ + payee.endpoint, + new LCHHttpServer({ + handlers: { + preflightDemand: async demand => { + const demandId = await objectId('payment-demand', demand.body) + if (demands.get(toHex(demandId))?.payee !== payee) + throw new Error('unknown Demand') + return new LCHPayee(payee.signer).createReadiness({ + demandId, + requestId: demand.body.requestId as Uint8Array, + buyer: demand.body.buyer as Uint8Array, + issuedAt: 1_000, + readyUntil: 1_100, + recoveryUntil: demand.body.recoveryUntil as number | bigint + }) + }, + paymentDelivery: async delivery => { + const demandId = delivery.body.demandId as Uint8Array + const runtime = demands.get(toHex(demandId)) + if (runtime?.payee !== payee) throw new Error('unknown Demand') + const transaction = Transaction.fromAtomicBEEF( + delivery.body.atomicBeef as AtomicBEEF + ) + return new LCHPayee(payee.signer).createReceipt({ + demandId, + requestId: delivery.body.requestId as Uint8Array, + txid: Uint8Array.from(transaction.id('array')), + outputIndex: delivery.body.outputIndex as number, + satoshis: payee.satoshis, + receivedAt: 1_100 + }) + } + } + }) + ] as const + ) + ) + const routedHttp = new LCHHttpAcquisitionClient({ + endpointPolicy: { + allowLocalOrigins: ['https://issuer.test', 'https://drummer.test', 'https://composer.test'], + connect: async (url, init) => { + const destinationUrl = url.toString() + const destination = + destinationUrl === endpoint ? server : payeeServers.get(destinationUrl) + if (destination === undefined) throw new Error(`unknown destination ${url}`) + return destination.handle(new Request(url, init)) + } + } + }) + const buyer = new LCHMultipayBuyer(buyerWallet.wallet, await walletSigner(121), { + now: () => 1_000n, + transport: routedHttp + }) + const request = await buyer.createRequest({ + offerId, + assetId, + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest: bytes(3, 32), + createdAt: 1_000 + }) + const plan = await buyer.quote(endpoint, request, issuerSigner.identityKey) + expect(plan.totalSatoshis).toBe(12n) + expect(plan.readiness).toHaveLength(2) + + const payment = await buyer.createPayment(plan) + expect(buyerWallet.createdActions()).toBe(1) + expect(payment.deliveries).toHaveLength(2) + expect(payment.deliveries.map(item => item.endpoint)).toEqual( + payees.map(payee => payee.endpoint) + ) + const receipts = await Promise.all( + payment.deliveries.map(delivery => buyer.deliver(payment, delivery)) + ) + const license = await buyer.complete(payment, receipts) + await expect(buyer.recover(endpoint, plan.requestId)).resolves.toEqual(license) + await expect(buyer.complete(payment, receipts.slice(1))).rejects.toThrow( + /one Receipt per Delivery/u + ) + await expect(buyer.complete(payment, [receipts[0]!, receipts[0]!])).rejects.toThrow( + /unexpected or repeated Receipt/u + ) + expect(buyerWallet.createdActions()).toBe(1) + }) + + it('refuses to fund at the signed Quote expiry boundary', async () => { + const signer = await walletSigner(131) + const buyer = new LCHMultipayBuyer(actionWallet(132).wallet, signer, { + now: () => 2_000n + }) + await expect( + buyer.createPayment({ + request: await signObject('license-request', { version: 1 }, signer), + requestId: bytes(1, 32), + quote: await signObject('quote', { version: 1 }, signer), + demands: [], + readiness: [], + authorizations: [], + issuer: signer.identityKey, + endpoint: 'https://multipay.test/lch', + totalSatoshis: 0n, + expiresAt: 2_000n, + recoveryUntil: 3_000n + }) + ).rejects.toThrow(/expired before transaction creation/u) + }) + + it('rejects independently returned Receipts that do not match the funded plan', async () => { + const buyerSigner = await walletSigner(141) + const payeeSigner = await walletSigner(142) + const requestId = bytes(1, 32) + const demand = await new LCHPayee(payeeSigner).createDemand({ + requestId, + offerId: bytes(2, 32), + dutyUid: 'urn:lch:duty:distributed', + buyer: buyerSigner.identityKey, + endpoint: 'https://drummer.test/payments', + satoshis: 7, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400 + }) + const demandId = await objectId('payment-demand', demand.body) + const transaction = new Transaction( + 1, + [], + [{ satoshis: 7, lockingScript: LockingScript.fromHex('51') }] + ) + const atomicBeef = Uint8Array.from(transaction.toAtomicBEEF(true)) + const delivery = await new LCHBuyer(buyerSigner).createPaymentDelivery({ + demandId, + requestId, + atomicBeef, + outputIndex: 0, + derivationPrefix: demand.body.derivationPrefix as Uint8Array, + derivationSuffix: bytes(3, 32) + }) + let receiptDemandId = demandId + let receiptRequestId = requestId + let receiptOutputIndex = 1 + let receiptSatoshis = 7 + const transport: LCHAcquisitionTransport = { + preflightLicense: async () => undefined, + quote: async () => { + throw new Error('unused') + }, + preflightDemand: async () => demand, + authorizePayment: async () => { + throw new Error('unused') + }, + deliver: async () => + new LCHPayee(payeeSigner).createReceipt({ + demandId: receiptDemandId, + requestId: receiptRequestId, + txid: Uint8Array.from(transaction.id('array')), + outputIndex: receiptOutputIndex, + satoshis: receiptSatoshis, + receivedAt: 1_100 + }), + complete: async () => { + throw new Error('unused') + }, + storeDelivery: async () => { + throw new Error('unused') + }, + attestTransaction: async () => { + throw new Error('unused') + }, + recover: async () => undefined + } + const request = await signObject( + 'license-request', + { version: 1, buyer: buyerSigner.identityKey }, + buyerSigner + ) + const quote = await signObject('quote', { version: 1 }, buyerSigner) + const funded = { + plan: { + request, + requestId, + quote, + demands: [demand], + readiness: [], + authorizations: [], + issuer: buyerSigner.identityKey, + endpoint: 'https://issuer.test/licenses', + totalSatoshis: 7n, + expiresAt: 2_000n, + recoveryUntil: 88_400n + }, + atomicBeef, + transactionState: 'finalized' as const, + deliveries: [ + { + demandId, + payee: payeeSigner.identityKey, + endpoint: 'https://drummer.test/payments', + delivery + } + ] + } + const buyer = new LCHMultipayBuyer(actionWallet(143).wallet, buyerSigner, { transport }) + await expect(buyer.deliver(funded, funded.deliveries[0]!)).rejects.toThrow( + /output index does not match/u + ) + receiptOutputIndex = 0 + receiptSatoshis = 8 + await expect(buyer.deliver(funded, funded.deliveries[0]!)).rejects.toThrow( + /amount does not match/u + ) + receiptSatoshis = 7 + receiptDemandId = bytes(9, 32) + const unknownDelivery = { ...funded.deliveries[0]!, demandId: receiptDemandId } + await expect(buyer.deliver(funded, unknownDelivery)).rejects.toThrow(/unknown Demand/u) + receiptDemandId = demandId + receiptRequestId = bytes(8, 32) + const wrongRequestReceipt = await transport.deliver('', delivery) + await expect(buyer.complete(funded, [wrongRequestReceipt])).rejects.toThrow( + /Receipt Request ID does not match/u + ) + }) + + it('falls back to authorized-output evidence when a Payee goes offline', async () => { + const buyerSigner = await walletSigner(151) + const payeeWallet = new ProtoWallet(new PrivateKey(152)) + const payeeSigner = await WalletBRC77Signer.create({ wallet: payeeWallet }) + const providerSigner = await walletSigner(153) + const issuerSigner = await walletSigner(154) + const offerId = bytes(1, 32) + const assetId = bytes(2, 32) + const request = await new LCHBuyer(buyerSigner).createRequest({ + offerId, + assetId, + action: 'play', + selection: { type: 'all' }, + acceptedPolicyDigest: bytes(3, 32), + createdAt: 1_000 + }) + const requestId = await objectId('license-request', request.body) + const demand = await new LCHPayee(payeeSigner).createDemand({ + requestId, + offerId, + dutyUid: 'urn:lch:duty:offline-drummer', + buyer: buyerSigner.identityKey, + endpoint: 'https://drummer.test/payments', + satoshis: 7, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400, + settlementProfile: LCH_SETTLEMENT_PROFILES.authorizedOutput + }) + const demandId = await objectId('payment-demand', demand.body) + const authorization = await new WalletAuthorizedOutputPayee({ + wallet: payeeWallet, + signer: payeeSigner, + now: () => 1_000n, + random: length => bytes(4, length) + }).authorize(demand, { + evidenceProvider: providerSigner.identityKey, + evidenceEndpoint: 'https://processor.test/evidence', + deliveryProvider: providerSigner.identityKey, + deliveryEndpoint: 'https://availability.test/store', + retrievalEndpoint: 'https://availability.test/retrieve' + }) + const transaction = new Transaction( + 1, + [], + [ + { + satoshis: 7, + lockingScript: LockingScript.fromHex( + toHex(authorization.body.lockingScript as Uint8Array) + ) + } + ] + ) + const atomicBeef = Uint8Array.from(transaction.toAtomicBEEF(true)) + const delivery = await new LCHBuyer(buyerSigner).createPaymentDelivery({ + demandId, + requestId, + atomicBeef, + outputIndex: 0, + derivationPrefix: authorization.body.derivationPrefix as Uint8Array, + derivationSuffix: authorization.body.derivationSuffix as Uint8Array + }) + const authorizationId = await objectId('payment-authorization', authorization.body) + const service = new LCHSettlementService(providerSigner) + const acknowledgement = await service.createDeliveryAcknowledgement({ + authorizationId, + deliveryId: await objectId('payment-delivery', delivery.body), + demandId, + requestId, + payee: payeeSigner.identityKey, + storedAt: 1_050, + availableUntil: 88_400, + retrievalEndpoint: 'https://availability.test/retrieve' + }) + const evidence = await service.createTransactionEvidence({ + authorizationId, + txid: Uint8Array.from(transaction.id('array')), + state: 'accepted', + policy: LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance, + observedAt: 1_050 + }) + const quote = await signObject('quote', { version: 1 }, issuerSigner) + let payeeOnline = false + const transport: LCHAcquisitionTransport = { + preflightLicense: async () => undefined, + quote: async () => quote, + preflightDemand: async () => demand, + authorizePayment: async () => authorization, + deliver: async () => { + if (!payeeOnline) throw new Error('Payee is offline') + return new LCHPayee(payeeSigner).createReceipt({ + demandId, + requestId, + txid: Uint8Array.from(transaction.id('array')), + outputIndex: 0, + satoshis: 7, + receivedAt: 1_050 + }) + }, + storeDelivery: async (endpoint, storedAuthorization, storedDelivery) => { + expect(endpoint).toBe('https://availability.test/store') + expect(storedAuthorization).toEqual(authorization) + expect(storedDelivery).toEqual(delivery) + return acknowledgement + }, + attestTransaction: async (endpoint, attestedAuthorization, beef) => { + expect(endpoint).toBe('https://processor.test/evidence') + expect(attestedAuthorization).toEqual(authorization) + expect(beef).toEqual(atomicBeef) + return evidence + }, + complete: async (_endpoint, completion) => { + expect(completion.receipts).toHaveLength(0) + expect(completion.authorizedOutputs).toHaveLength(1) + return signObject( + 'license', + { version: 1, requestId, subject: buyerSigner.identityKey }, + issuerSigner + ) + }, + recover: async () => undefined + } + const item = { + demandId, + payee: payeeSigner.identityKey, + endpoint: 'https://drummer.test/payments', + delivery + } + const funded = { + plan: { + request, + requestId, + quote, + demands: [demand], + readiness: [], + authorizations: [authorization], + issuer: issuerSigner.identityKey, + endpoint: 'https://issuer.test/licenses', + totalSatoshis: 7n, + expiresAt: 2_000n, + recoveryUntil: 88_400n + }, + atomicBeef, + transactionState: 'finalized' as const, + deliveries: [item] + } + const buyer = new LCHMultipayBuyer(actionWallet(155).wallet, buyerSigner, { transport }) + const offlineSettlement = await buyer.settleDelivery(funded, item) + expect(offlineSettlement.type).toBe('authorized-output') + if (offlineSettlement.type !== 'authorized-output') throw new Error('unexpected settlement') + await expect(buyer.complete(funded, [], [offlineSettlement.evidence])).resolves.toMatchObject({ + body: { requestId, subject: buyerSigner.identityKey } + }) + + payeeOnline = true + await expect(buyer.settleDelivery(funded, item)).resolves.toMatchObject({ type: 'receipt' }) + }) +}) + +async function walletSigner(privateKey: number): Promise { + return WalletBRC77Signer.create({ wallet: new ProtoWallet(new PrivateKey(privateKey)) }) +} + +function actionWallet(privateKey: number): { + wallet: WalletInterface + createdActions(): number +} { + const proto = new ProtoWallet(new PrivateKey(privateKey)) + let actions = 0 + const wallet = new Proxy(proto as unknown as WalletInterface, { + get(target, property, receiver) { + if (property === 'createAction') + return async (args: CreateActionArgs) => { + actions += 1 + const outputs = (args.outputs ?? []) + .map(output => ({ + satoshis: output.satoshis, + lockingScript: LockingScript.fromHex(output.lockingScript) + })) + .reverse() + const transaction = new Transaction(1, [], outputs) + return { txid: transaction.id('hex'), tx: transaction.toAtomicBEEF(true) } + } + const value = Reflect.get(target, property, receiver) as unknown + return typeof value === 'function' ? value.bind(target) : value + } + }) + return { wallet, createdActions: () => actions } +} diff --git a/packages/content/lch/test/profiles-core.test.ts b/packages/content/lch/test/profiles-core.test.ts new file mode 100644 index 000000000..06af3699a --- /dev/null +++ b/packages/content/lch/test/profiles-core.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { + CORE_CAPABILITIES, + LCHAcquisition, + LCHIssuer, + LCHPublisher, + LCHReader, + LCH_MECHANISMS, + LCH_PROFILES, + MemoryContentSink, + MemoryLicenseStore, + PublicBRC77Verifier, + WalletBRC77Signer, + fixedTotal, + fromBase64Url, + fromHex, + normalizeSelection, + parsePinnedPolicy, + permits, + requireProfile, + requireActiveTimeWindow, + selectionQuantity, + selectionsIntersect, + sha256, + signObject, + supportsProfile, + timeWindowStatus, + toBase64Url, + toHex, + unitAmount, + validateOffer, + type LCHValue, + type SignedObject +} from '../src/index.js' + +const bytes = (value: number, length: number): Uint8Array => new Uint8Array(length).fill(value) + +describe('profiles, selections, prices, and policies', () => { + it('accepts only fully understood acquisition profiles', () => { + const offered = { + usageProfile: LCH_PROFILES.fixedRender, + payment: LCH_MECHANISMS.brc105Single, + keyDelivery: LCH_MECHANISMS.brc78Key, + encryption: LCH_MECHANISMS.encryption, + enforcement: 'https://bsv.brc.dev/apps/0170#conformingApplication' + } + expect(supportsProfile(offered)).toBe(true) + expect(() => requireProfile(offered)).not.toThrow() + expect( + supportsProfile({ + ...offered, + critical: ['https://application.example/unknown-profile'] + }) + ).toBe(false) + expect(CORE_CAPABILITIES.compositionMappings.has(LCH_MECHANISMS.wholePlacement)).toBe(true) + }) + + it('normalizes selections and checks exact integer pricing', () => { + expect( + normalizeSelection({ + type: 'segments', + ranges: [ + [3, 5], + [1, 3] + ] + }) + ).toEqual({ type: 'segments', ranges: [[1n, 5n]] }) + expect( + selectionsIntersect({ type: 'bytes', ranges: [[1, 4]] }, { type: 'bytes', ranges: [[3, 6]] }) + ).toBe(true) + expect(selectionQuantity({ type: 'pages', ranges: [[2, 5]] })).toBe(3n) + expect(fixedTotal([{ satoshis: 3 }, { satoshis: 4n }])).toBe(7n) + expect(unitAmount(11, 5, 1, 2)).toBe(6n) + expect(() => unitAmount(1.5, 1, 1, 1)).toThrow() + }) + + it('evaluates pinned policies without rewriting nested placeholders', async () => { + const policy = { + '@context': ['http://www.w3.org/ns/odrl.jsonld'], + '@type': 'Offer', + uid: 'lch:offer:self', + profile: 'https://bsv.brc.dev/apps/0170#odrl-profile', + permission: [{ target: 'lch:asset:sha256:one', action: 'play' }], + prohibition: [{ target: 'lch:asset:sha256:one', action: 'unwrap' }] + } + const inline = new TextEncoder().encode(JSON.stringify(policy)) + const parsed = await parsePinnedPolicy( + { mediaType: 'application/ld+json', digest: await sha256(inline), inline }, + 'Offer', + 'lch:offer:sha256:computed' + ) + expect(permits(parsed, 'play', 'lch:asset:sha256:one')).toBe(true) + expect(permits(parsed, 'unwrap', 'lch:asset:sha256:one')).toBe(false) + }) + + it('round trips strict binary text encodings', () => { + const value = Uint8Array.of(0, 1, 254, 255) + expect(fromBase64Url(toBase64Url(value))).toEqual(value) + expect(fromHex(toHex(value))).toEqual(value) + expect(() => fromHex('AA')).toThrow() + }) + + it('uses half-open time windows at exact rental boundaries', () => { + const window = { notBefore: 1_000, notAfter: 2_000 } + expect(timeWindowStatus(window, 999)).toBe('not-started') + expect(timeWindowStatus(window, 1_000)).toBe('active') + expect(timeWindowStatus(window, 1_999)).toBe('active') + expect(timeWindowStatus(window, 2_000)).toBe('expired') + expect(() => requireActiveTimeWindow(window, 1_000)).not.toThrow() + expect(() => requireActiveTimeWindow(window, 2_000)).toThrow() + expect(() => timeWindowStatus({ notBefore: 2_000, notAfter: 2_000 }, 2_000)).toThrow() + expect(() => timeWindowStatus({ notBefore: -1 }, 0)).toThrow() + expect(timeWindowStatus({}, 0n)).toBe('active') + expect(timeWindowStatus({ notBefore: 10n }, 10n)).toBe('active') + expect(timeWindowStatus({ notAfter: 10n }, 9n)).toBe('active') + expect(() => timeWindowStatus({ notAfter: -1 }, 0)).toThrow() + expect(() => timeWindowStatus({}, 0.5)).toThrow() + }) +}) + +describe('issuer and acquisition orchestration', () => { + it('signs and verifies Offers and exact whole-asset key Licenses', async () => { + const signer = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(31)), + random: length => bytes(31, length) + }) + const issuer = new LCHIssuer(signer) + const policyBytes = new TextEncoder().encode('{}') + const offer = await issuer.createOffer({ + assetId: bytes(1, 32), + usageProfile: LCH_PROFILES.fixedRender, + seller: signer.identityKey, + licenseIssuer: signer.identityKey, + requiredInterests: ['master'], + policy: { + mediaType: 'application/ld+json', + digest: await sha256(policyBytes), + inline: policyBytes + }, + payment: { + protocol: LCH_MECHANISMS.brc105Single, + recoveryPeriodSeconds: 86_400 + }, + keyDelivery: { mechanism: LCH_MECHANISMS.brc78Key }, + enforcement: { + class: 'https://bsv.brc.dev/apps/0170#conformingApplication' + }, + notBefore: 1, + nonce: bytes(2, 16) + }) + await expect( + validateOffer(offer, new PublicBRC77Verifier(), signer.identityKey) + ).resolves.toMatch(/^lch:offer:sha256:/u) + const emptyWindow = await signObject( + 'offer', + { ...offer.body, notAfter: offer.body.notBefore }, + signer + ) + await expect( + validateOffer(emptyWindow, new PublicBRC77Verifier(), signer.identityKey) + ).rejects.toMatchObject({ code: 'ERR_LCH_LICENSE' }) + const missingNotBeforeBody = { ...offer.body } + delete missingNotBeforeBody.notBefore + const missingNotBefore = await signObject('offer', missingNotBeforeBody, signer) + await expect( + validateOffer(missingNotBefore, new PublicBRC77Verifier(), signer.identityKey) + ).rejects.toMatchObject({ code: 'ERR_LCH_LICENSE' }) + const invalidNotAfter = await signObject('offer', { ...offer.body, notAfter: 'later' }, signer) + await expect( + validateOffer(invalidNotAfter, new PublicBRC77Verifier(), signer.identityKey) + ).rejects.toMatchObject({ code: 'ERR_LCH_LICENSE' }) + expect(issuer.quoteFixed([{ satoshis: 2 }, { satoshis: 3 }])).toBe(5n) + + const license = await issuer.issueLicense({ + assetId: bytes(1, 32), + offerId: bytes(3, 32), + requestId: bytes(4, 32), + issuer: signer.identityKey, + subject: bytes(5, 33), + issuedAt: 10, + agreement: { + mediaType: 'application/ld+json', + digest: await sha256(policyBytes), + inline: policyBytes + }, + selection: { + type: 'segments', + ranges: [ + [2, 3], + [1, 2] + ] + } + }) + expect(license.body.selection).toEqual({ + type: 'segments', + ranges: [[1n, 3n]] + }) + }) + + it('keeps preflight, quote, payment delivery, and recovery explicit', async () => { + const object: SignedObject = { body: {}, signatures: [] } + const transport = { + preflight: jest.fn(async () => undefined), + quote: jest.fn(async () => object), + deliver: jest.fn(async () => object), + recover: jest.fn(async () => object) + } + const acquisition = new LCHAcquisition(transport) + await acquisition.preflight(object) + await expect(acquisition.quote(object)).resolves.toBe(object) + await expect(acquisition.deliver(object, Uint8Array.of(1))).resolves.toBe(object) + await expect(acquisition.recover(bytes(1, 32))).resolves.toBe(object) + expect(transport.deliver).toHaveBeenCalledWith(object, Uint8Array.of(1)) + }) + + it('authorizes a delegated Header signer only through the application callback', async () => { + const controller = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(32)), + random: length => bytes(32, length) + }) + const delegate = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(33)), + random: length => bytes(33, length) + }) + const storage = new MemoryContentSink() + const protectedAsset = await new LCHPublisher(delegate).protect(Uint8Array.of(1), { + mediaType: 'application/octet-stream', + name: 'delegated.bin', + rights: [ + { + interest: 'master', + holder: { name: 'Controller' }, + controller: controller.identityKey + } + ], + sink: storage, + random: length => Uint8Array.from({ length }, (_, index) => length + index) + }) + const published = await new LCHPublisher(delegate).publish( + protectedAsset, + [{ mode: 'discover', endpoint: 'https://seller.example' }], + false + ) + await expect(new LCHReader(storage).inspect(published.bytes)).rejects.toMatchObject({ + code: 'ERR_LCH_AUTHORITY' + }) + const authorizeHeaderSigner = jest.fn(async () => true) + await expect( + new LCHReader(storage, undefined, { authorizeHeaderSigner }).inspect(published.bytes) + ).resolves.toMatchObject({ assetId: protectedAsset.assetId }) + expect(authorizeHeaderSigner).toHaveBeenCalledWith(delegate.identityKey, protectedAsset.assetId) + }) + + it('retrieves stored Licenses through the reader', async () => { + const store = new MemoryLicenseStore() + const license: SignedObject> = { + body: { version: 1 }, + signatures: [] + } + await store.put({ assetId: '01', offerId: '02', license, storedAt: 1n }) + const reader = new LCHReader(new MemoryContentSink(), store) + await expect(reader.storedLicense(Uint8Array.of(1), Uint8Array.of(2))).resolves.toEqual(license) + }) +}) diff --git a/packages/content/lch/test/security.test.ts b/packages/content/lch/test/security.test.ts new file mode 100644 index 000000000..78a7a76e3 --- /dev/null +++ b/packages/content/lch/test/security.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from '@jest/globals' +import { isPublicAddress, validateEndpoint } from '../src/index.js' + +describe('endpoint trust', () => { + it.each(['127.0.0.1', '10.2.3.4', '169.254.2.3', '192.168.1.1', '::1', 'fc00::1', '2001:db8::1'])( + 'rejects non-public address %s', + address => expect(isPublicAddress(address)).toBe(false) + ) + + it('requires public DNS validation and accepts explicit local development origins', async () => { + expect(isPublicAddress('999.1.1.1')).toBe(false) + expect(isPublicAddress('::ffff:8.8.8.8')).toBe(true) + expect(isPublicAddress('::ffff:127.0.0.1')).toBe(false) + await expect(validateEndpoint('not-an-absolute-url')).rejects.toMatchObject({ + code: 'ERR_LCH_ENDPOINT' + }) + await expect(validateEndpoint('https://8.8.8.8/content')).resolves.toBeInstanceOf(URL) + await expect(validateEndpoint('https://example.com/content')).rejects.toMatchObject({ + code: 'ERR_LCH_ENDPOINT' + }) + await expect( + validateEndpoint('https://example.com/content', { + resolve: async () => ['93.184.216.34'] + }) + ).resolves.toBeInstanceOf(URL) + await expect( + validateEndpoint('https://example.com/content', { + resolve: async () => ['127.0.0.1'] + }) + ).rejects.toMatchObject({ code: 'ERR_LCH_ENDPOINT' }) + await expect(validateEndpoint('https://127.0.0.1/content')).rejects.toMatchObject({ + code: 'ERR_LCH_ENDPOINT' + }) + await expect( + validateEndpoint('https://127.0.0.1/content', { allowLocalOrigins: ['https://127.0.0.1'] }) + ).resolves.toBeInstanceOf(URL) + }) +}) diff --git a/packages/content/lch/test/settlement.test.ts b/packages/content/lch/test/settlement.test.ts new file mode 100644 index 000000000..c8c503010 --- /dev/null +++ b/packages/content/lch/test/settlement.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from '@jest/globals' +import { LockingScript, PrivateKey, ProtoWallet, Transaction } from '@bsv/sdk' +import { + LCHBuyer, + LCHPayee, + LCHSettlementService, + LCH_SETTLEMENT_PROFILES, + LCH_TRANSACTION_EVIDENCE_POLICIES, + WalletAuthorizedOutputPayee, + WalletBRC77Signer, + objectId, + signObject, + toHex, + validateAuthorizedOutputEvidence, + validatePaymentAuthorization, + validatePaymentDeliveryRetrieval, + type SignedObject +} from '../src/index.js' + +describe('authorized-output settlement profile', () => { + it('releases a verifiable proof bundle while the Payee wallet is unavailable', async () => { + const fixture = await authorizedFixture() + await expect( + validateAuthorizedOutputEvidence(fixture.bundle, fixture.demand, fixture.atomicBeef) + ).resolves.toEqual(fixture.demandId) + const retrieval = await new LCHPayee(fixture.payeeSigner).createDeliveryRetrieval({ + authorizationId: fixture.authorizationId, + requestedAt: 2_001, + nonce: new Uint8Array(16).fill(6) + }) + await expect( + validatePaymentDeliveryRetrieval(retrieval, fixture.bundle.authorization) + ).resolves.toEqual(await objectId('payment-delivery-retrieval', retrieval.body)) + + const lowEvidence = await signObject( + 'transaction-evidence', + { + version: 1, + authorizationId: fixture.authorizationId, + txid: Uint8Array.from(fixture.transaction.id('array')), + provider: fixture.providerSigner.identityKey, + state: 'broadcast', + policy: LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance, + observedAt: 1_050 + }, + fixture.providerSigner + ) + await expect( + validateAuthorizedOutputEvidence( + { ...fixture.bundle, transactionEvidence: lowEvidence }, + fixture.demand, + fixture.atomicBeef + ) + ).rejects.toThrow(/minimum state/u) + + const shortRetention = await new LCHSettlementService( + fixture.providerSigner + ).createDeliveryAcknowledgement({ + authorizationId: fixture.authorizationId, + deliveryId: await objectId('payment-delivery', fixture.bundle.delivery.body), + demandId: fixture.demandId, + requestId: fixture.requestId, + payee: fixture.payeeSigner.identityKey, + storedAt: 1_050, + availableUntil: 2_001, + retrievalEndpoint: 'https://availability.test/retrieve' + }) + await expect( + validateAuthorizedOutputEvidence( + { ...fixture.bundle, deliveryAcknowledgement: shortRetention }, + fixture.demand, + fixture.atomicBeef + ) + ).rejects.toThrow(/recovery deadline/u) + }) + + it('rejects a substituted output and an unauthorized evidence provider', async () => { + const fixture = await authorizedFixture() + const wrongTransaction = new Transaction( + 1, + [], + [{ satoshis: 7, lockingScript: LockingScript.fromHex('51') }] + ) + const wrongBeef = Uint8Array.from(wrongTransaction.toAtomicBEEF(true)) + await expect( + validateAuthorizedOutputEvidence(fixture.bundle, fixture.demand, wrongBeef) + ).rejects.toThrow(/Completion Atomic BEEF does not match/u) + + const otherProvider = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(215)) + }) + const foreignEvidence = await new LCHSettlementService(otherProvider).createTransactionEvidence( + { + authorizationId: fixture.authorizationId, + txid: Uint8Array.from(fixture.transaction.id('array')), + state: 'accepted', + policy: LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance, + observedAt: 1_050 + } + ) + await expect( + validateAuthorizedOutputEvidence( + { ...fixture.bundle, transactionEvidence: foreignEvidence }, + fixture.demand, + fixture.atomicBeef + ) + ).rejects.toThrow(/Evidence provider does not match/u) + }) + + it('binds one idempotent Authorization to the exact Demand and expiry', async () => { + const fixture = await authorizedFixture() + await expect( + validatePaymentAuthorization(fixture.bundle.authorization, fixture.demand, 1_999) + ).resolves.toEqual(fixture.authorizationId) + await expect( + validatePaymentAuthorization(fixture.bundle.authorization, fixture.demand, 2_000) + ).rejects.toThrow(/not currently valid/u) + expect(await fixture.authorizer.authorize(fixture.demand, fixture.policy)).toEqual( + fixture.bundle.authorization + ) + }) +}) + +async function authorizedFixture(): Promise<{ + demand: SignedObject + demandId: Uint8Array + requestId: Uint8Array + authorizationId: Uint8Array + atomicBeef: Uint8Array + transaction: Transaction + bundle: { + authorization: SignedObject + delivery: SignedObject + transactionEvidence: SignedObject + deliveryAcknowledgement: SignedObject + } + authorizer: WalletAuthorizedOutputPayee + policy: Parameters[1] + payeeSigner: WalletBRC77Signer + providerSigner: WalletBRC77Signer +}> { + const buyerSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(211)) + }) + const payeeWallet = new ProtoWallet(new PrivateKey(212)) + const payeeSigner = await WalletBRC77Signer.create({ wallet: payeeWallet }) + const providerSigner = await WalletBRC77Signer.create({ + wallet: new ProtoWallet(new PrivateKey(213)) + }) + const requestId = new Uint8Array(32).fill(1) + const demand = await new LCHPayee(payeeSigner).createDemand({ + requestId, + offerId: new Uint8Array(32).fill(2), + dutyUid: 'urn:lch:duty:drummer', + buyer: buyerSigner.identityKey, + endpoint: 'https://drummer.test/payments', + satoshis: 7, + expiresAt: 2_000, + recoveryPeriodSeconds: 86_400, + settlementProfile: LCH_SETTLEMENT_PROFILES.authorizedOutput + }) + const demandId = await objectId('payment-demand', demand.body) + const authorizer = new WalletAuthorizedOutputPayee({ + wallet: payeeWallet, + signer: payeeSigner, + now: () => 1_000n, + random: length => new Uint8Array(length).fill(3) + }) + const policy = { + evidenceProvider: providerSigner.identityKey, + evidenceEndpoint: 'https://processor.test/evidence', + deliveryProvider: providerSigner.identityKey, + deliveryEndpoint: 'https://availability.test/store', + retrievalEndpoint: 'https://availability.test/retrieve' + } + const authorization = await authorizer.authorize(demand, policy) + const authorizationId = await objectId('payment-authorization', authorization.body) + const transaction = new Transaction( + 1, + [], + [ + { + satoshis: 7, + lockingScript: LockingScript.fromHex(toHex(authorization.body.lockingScript as Uint8Array)) + } + ] + ) + const atomicBeef = Uint8Array.from(transaction.toAtomicBEEF(true)) + const delivery = await new LCHBuyer(buyerSigner).createPaymentDelivery({ + demandId, + requestId, + atomicBeef, + outputIndex: 0, + derivationPrefix: authorization.body.derivationPrefix as Uint8Array, + derivationSuffix: authorization.body.derivationSuffix as Uint8Array + }) + const service = new LCHSettlementService(providerSigner) + const transactionEvidence = await service.createTransactionEvidence({ + authorizationId, + txid: Uint8Array.from(transaction.id('array')), + state: 'accepted', + policy: LCH_TRANSACTION_EVIDENCE_POLICIES.signedProcessorAcceptance, + observedAt: 1_050 + }) + const deliveryAcknowledgement = await service.createDeliveryAcknowledgement({ + authorizationId, + deliveryId: await objectId('payment-delivery', delivery.body), + demandId, + requestId, + payee: payeeSigner.identityKey, + storedAt: 1_050, + availableUntil: 88_400, + retrievalEndpoint: 'https://availability.test/retrieve' + }) + return { + demand, + demandId, + requestId, + authorizationId, + atomicBeef, + transaction, + bundle: { authorization, delivery, transactionEvidence, deliveryAcknowledgement }, + authorizer, + policy, + payeeSigner, + providerSigner + } +} diff --git a/packages/content/lch/test/wallet-payment.test.ts b/packages/content/lch/test/wallet-payment.test.ts new file mode 100644 index 000000000..7bc0ceb38 --- /dev/null +++ b/packages/content/lch/test/wallet-payment.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { LockingScript, P2PKH, PrivateKey, Transaction } from '@bsv/sdk' +import { createMultipayTransaction } from '../src/index.js' + +describe('wallet multilateral payment integration', () => { + it('finds Demand outputs after the wallet changes their order', async () => { + const publicKeys = [ + new PrivateKey(11).toPublicKey().toString(), + new PrivateKey(12).toPublicKey().toString() + ] + let key = 0 + const getPublicKey = jest.fn(async () => ({ publicKey: publicKeys[key++] })) + const createAction = jest.fn( + async (args: { outputs: Array<{ satoshis: number; lockingScript: string }> }) => { + const reordered = [args.outputs[1], { satoshis: 1, lockingScript: '51' }, args.outputs[0]] + const transaction = new Transaction( + 1, + [], + reordered.map(output => ({ + satoshis: output.satoshis, + lockingScript: LockingScript.fromHex(output.lockingScript) + })) + ) + return { tx: transaction.toAtomicBEEF(true) } + } + ) + const result = await createMultipayTransaction( + { getPublicKey, createAction } as never, + [ + { + demandId: new Uint8Array(32).fill(1), + payee: new Uint8Array(33).fill(2), + satoshis: 7n, + derivationPrefix: new Uint8Array(32).fill(3), + dutyUid: 'recording' + }, + { + demandId: new Uint8Array(32).fill(4), + payee: new Uint8Array(33).fill(5), + satoshis: 5n, + derivationPrefix: new Uint8Array(32).fill(6), + dutyUid: 'composition' + } + ], + { random: length => new Uint8Array(length).fill(8) } + ) + expect(result.remittances.map(item => item.outputIndex)).toEqual([2, 0]) + expect(createAction.mock.calls[0][0]).not.toHaveProperty('options.randomizeOutputs') + }) + + it('rejects a Payee-authorized script mismatch before creating a wallet action', async () => { + const publicKeys = [new PrivateKey(21).toPublicKey(), new PrivateKey(22).toPublicKey()] + let key = 0 + const getPublicKey = jest.fn(async () => ({ publicKey: publicKeys[key++]!.toString() })) + const createAction = jest.fn() + await expect( + createMultipayTransaction({ getPublicKey, createAction } as never, [ + { + demandId: new Uint8Array(32).fill(1), + payee: new Uint8Array(33).fill(2), + satoshis: 7n, + derivationPrefix: new Uint8Array(32).fill(3), + dutyUid: 'recording', + authorizedOutput: { + derivationSuffix: new Uint8Array(32).fill(4), + lockingScript: new P2PKH().lock(publicKeys[1]!.toAddress()).toUint8Array() + } + }, + { + demandId: new Uint8Array(32).fill(5), + payee: new Uint8Array(33).fill(6), + satoshis: 5n, + derivationPrefix: new Uint8Array(32).fill(7), + dutyUid: 'composition' + } + ]) + ).rejects.toThrow(/does not match the Payee Authorization/u) + expect(createAction).not.toHaveBeenCalled() + }) +}) diff --git a/packages/content/lch/tsconfig.json b/packages/content/lch/tsconfig.json new file mode 100644 index 000000000..b7a692db2 --- /dev/null +++ b/packages/content/lch/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../config/typescript/dual-runtime.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/network/chirp/README.md b/packages/network/chirp/README.md index 741173acd..b769c7808 100644 --- a/packages/network/chirp/README.md +++ b/packages/network/chirp/README.md @@ -120,8 +120,26 @@ Storage hosts must use HTTPS unless `allowInsecureHTTP` (or the CLI's - `mediaType` is untrusted advisory metadata. CHIRP integrity is not author authenticity or permission to execute content. -The BRC-167 serialization is authoritative if package behavior and the -standard ever disagree. +## Production integration + +Set `resilienceLevel` to the number of complete hosts required for a successful +publication, retain resumable checkpoints until every intended host commits, +and monitor root retention and renewal. A root advertisement means the host +validated and retains the complete transitive closure; do not describe a +partial cache as a complete host. Readers should bound logical bytes, object +bytes and count, depth, retries, concurrency, redirects, and cache use, and +server-side readers should enforce a connection-pinned public-address policy. + +For licensed media, publish LCH ciphertext through `CHIRPContentSink` and put +the returned `chirp:` locator in the LCH Asset representation. CHIRP then owns +verified availability while LCH independently owns encryption, rights, +payment, keys, and composition. See the +[production CHIRP and LCH guide](https://github.com/bsv-blockchain/ts-stack/blob/main/docs/guides/chirp-lch-production.md) +for the complete topology, persistence model, failure matrix, deployment gate, +and agent implementation contract. + +See [BRC-167](https://bsv.brc.dev/overlays/0167) for the normative protocol. If +package behavior and the standard differ, the standard is authoritative. ## License diff --git a/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts b/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts index a331b4b96..5c40cd7d7 100644 --- a/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts +++ b/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts @@ -20,8 +20,11 @@ const nameSegment = fc.stringMatching(/^[a-z]{1,8}$/) const serviceName = fc .tuple(fc.constantFrom('tm_', 'ls_'), fc.array(nameSegment, { minLength: 1, maxLength: 5 })) .map(([prefix, segments]) => `${prefix}${segments.join('_')}`) +const asciiDnsLabel = fc + .stringMatching(/^[a-z][a-z0-9-]{0,12}[a-z0-9]$|^[a-z]$/) + .filter(label => !label.startsWith('xn--')) const hostname = fc - .array(fc.stringMatching(/^[a-z][a-z0-9-]{0,12}[a-z0-9]$|^[a-z]$/), { + .array(asciiDnsLabel, { minLength: 1, maxLength: 4 }) @@ -78,6 +81,7 @@ describe('overlay discovery boundary properties', () => { 'https://%', 'https+bsvauth://%', 'wss://%', + 'https://xn--0.org', 'https://localhost', 'wss://localhost', 'https://example.org/path', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea9c99f40..12e81a408 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,34 @@ importers: specifier: ^2.9.0 version: 2.9.0 + apps/lch-reference: + dependencies: + '@bsv/lch': + specifier: workspace:^ + version: link:../../packages/content/lch + '@bsv/sdk': + specifier: workspace:^ + version: link:../../packages/sdk + devDependencies: + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@typescript/native': + specifier: npm:typescript@7.0.2 + version: typescript@7.0.2 + oxlint: + specifier: ^1.76.0 + version: 1.76.0 + typescript: + specifier: npm:@typescript/typescript6@6.0.2 + version: '@typescript/typescript6@6.0.2' + vite: + specifier: ^8.1.5 + version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + conformance/runner: dependencies: ajv: @@ -186,6 +214,42 @@ importers: specifier: ^8.1.5 version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + packages/content/lch: + devDependencies: + '@bsv/chirp': + specifier: workspace:^ + version: link:../../network/chirp + '@bsv/sdk': + specifier: workspace:^ + version: link:../../sdk + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@typescript/native': + specifier: npm:typescript@7.0.2 + version: typescript@7.0.2 + fast-check: + specifier: ^4.9.0 + version: 4.9.0 + jest: + specifier: ^30.4.2 + version: 30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2)) + oxlint: + specifier: ^1.76.0 + version: 1.76.0 + ts-jest: + specifier: ^29.4.12 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(@typescript/typescript6@6.0.2)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2))) + typescript: + specifier: npm:@typescript/typescript6@6.0.2 + version: '@typescript/typescript6@6.0.2' + packages/helpers/air-gap: devDependencies: '@types/jest': diff --git a/scripts/check-package-license-tarballs.mjs b/scripts/check-package-license-tarballs.mjs index ed9b0325a..4b7f0bd34 100644 --- a/scripts/check-package-license-tarballs.mjs +++ b/scripts/check-package-license-tarballs.mjs @@ -89,8 +89,8 @@ async function mapWithConcurrency(items, concurrency, operation) { } const errors = (await mapWithConcurrency(packages, 8, verifyPackage)).flat() -if (packages.length !== 32) { - errors.push(`Expected 32 public npm packages, found ${packages.length}`) +if (packages.length !== 33) { + errors.push(`Expected 33 public npm packages, found ${packages.length}`) } if (errors.length > 0) { diff --git a/scripts/configure-ts-stack-npm-trust.sh b/scripts/configure-ts-stack-npm-trust.sh index 0fc894072..1459dcca3 100755 --- a/scripts/configure-ts-stack-npm-trust.sh +++ b/scripts/configure-ts-stack-npm-trust.sh @@ -20,6 +20,7 @@ PKGS=( "@bsv/payment-express-middleware" "@bsv/teranode-listener" "@bsv/chirp" + "@bsv/lch" "@bsv/gasp" "@bsv/overlay-discovery-services" "@bsv/overlay-express" diff --git a/scripts/contributor-policy.test.mjs b/scripts/contributor-policy.test.mjs index fa55f2473..462bd9b1d 100644 --- a/scripts/contributor-policy.test.mjs +++ b/scripts/contributor-policy.test.mjs @@ -14,7 +14,7 @@ import { test('current contributor and agent policy is uniform across the governed stack', () => { const result = evaluateContributorPolicy() assert.deepEqual(result.errors, []) - assert.equal(result.summary.scopedProjectsAndServices, 45) + assert.equal(result.summary.scopedProjectsAndServices, 47) assert.equal(result.summary.consolidatedLegacyAgentFiles, 31) assert.equal(result.summary.historicalGitHubFiles, 49) assert.equal(result.summary.retiredPackageContributionFiles, 8) diff --git a/scripts/package-documentation.mjs b/scripts/package-documentation.mjs index 111582287..38f2858a5 100644 --- a/scripts/package-documentation.mjs +++ b/scripts/package-documentation.mjs @@ -219,7 +219,7 @@ tags: [reference, packages, api, declarations, migrations, release-notes] # Package API, Declarations, and Migration Ledger -This page is generated from all 32 public manifests, package documentation, and +This page is generated from all 33 public manifests, package documentation, and \`governance/package-release-notes.json\`. It records source candidates without publishing them. CI rejects a version change unless its release classification, summary, and migration guidance are updated at the same time. diff --git a/scripts/package-documentation.test.mjs b/scripts/package-documentation.test.mjs index 6f218a48f..f2287cb98 100644 --- a/scripts/package-documentation.test.mjs +++ b/scripts/package-documentation.test.mjs @@ -5,8 +5,8 @@ import { loadPackageDocumentation, renderPackageDocumentation } from './package- test('package API and migration ledger covers every public package', async () => { const model = await loadPackageDocumentation() assert.deepEqual(model.errors, []) - assert.equal(model.packages.length, 32) - assert.equal(model.packages.filter(pkg => pkg.releaseType !== 'none').length, 32) + assert.equal(model.packages.length, 33) + assert.equal(model.packages.filter(pkg => pkg.releaseType !== 'none').length, 33) assert.ok(model.packages.every(pkg => pkg.docsPath?.startsWith('docs/packages/'))) const rendered = renderPackageDocumentation(model) diff --git a/scripts/package-license-policy.test.mjs b/scripts/package-license-policy.test.mjs index 76f31f46a..b722f5a6d 100644 --- a/scripts/package-license-policy.test.mjs +++ b/scripts/package-license-policy.test.mjs @@ -25,7 +25,7 @@ test('all package projects use the exact current Open BSV license', () => { assert.equal(LICENSE_FILE, 'LICENSE.txt') assert.equal(LICENSE_DECLARATION, 'SEE LICENSE IN LICENSE.txt') assert.equal(OCI_LICENSE_REFERENCE, 'LicenseRef-Open-BSV-License-6') - assert.equal(discoverPackageManifests().length, 48) + assert.equal(discoverPackageManifests().length, 50) assert.deepEqual(validatePackageLicenses(), []) }) diff --git a/scripts/package-release-artifacts.mjs b/scripts/package-release-artifacts.mjs index ad4192d6c..e50a5596e 100644 --- a/scripts/package-release-artifacts.mjs +++ b/scripts/package-release-artifacts.mjs @@ -171,8 +171,8 @@ async function loadGovernedProjects() { path.join(REPOSITORY_ROOT, 'governance/repository-health/projects.json') ) const projects = governedProjects(registry) - if (projects.length !== 32) { - throw new Error(`expected 32 governed npm packages, found ${projects.length}`) + if (projects.length !== 33) { + throw new Error(`expected 33 governed npm packages, found ${projects.length}`) } return await Promise.all( projects.map(async project => { diff --git a/scripts/patch-coverage.mjs b/scripts/patch-coverage.mjs index 5b41bb25d..46fdb8f13 100644 --- a/scripts/patch-coverage.mjs +++ b/scripts/patch-coverage.mjs @@ -20,6 +20,11 @@ const EXCLUDED_SOURCE_PATTERNS = [ // Benchmark orchestration and type-only declarations have no executable // statements for Jest/Istanbul to instrument. /packages\/sdk\/scripts\/run-benchmarks\.js$/, + // The LCH vector regenerator is release tooling that executes the compiled + // package against a separately reviewed BRC fixture. Package coverage is + // deliberately collected from `src/**/*.ts`; this script is instead + // exercised by deterministic regeneration and byte-for-byte vector checks. + /packages\/content\/lch\/scripts\/regenerate-brc170-vectors\.mjs$/, /\.interfaces\.[cm]?[jt]sx?$/, /packages\/wallet\/wallet-toolbox\/src\/storage\/schema\/StorageIdbSchema\.ts$/, // A `*.md.ts` module is one exported template literal, which is a convention @@ -53,6 +58,11 @@ const EXCLUDED_SOURCE_PATTERNS = [ // intentionally not part of this exact exclusion. /packages\/network\/chirp\/src\/index\.ts$/, /packages\/network\/chirp\/src\/types\.ts$/, + // LCH follows the same package shape: its entry point is only re-exports and + // its types module emits declarations only. Executable source remains in the + // patch-coverage boundary. + /packages\/content\/lch\/src\/index\.ts$/, + /packages\/content\/lch\/src\/types\.ts$/, // These ChainTracks modules emit no executable statements: two contain // interfaces/type-only imports and the mobile entry point only re-exports // platform-safe implementations. Keep the exclusions exact so executable diff --git a/scripts/patch-coverage.test.mjs b/scripts/patch-coverage.test.mjs index 77c0d53cf..4b07f4403 100644 --- a/scripts/patch-coverage.test.mjs +++ b/scripts/patch-coverage.test.mjs @@ -97,6 +97,9 @@ diff --git a/packages/sdk/benchmarks/example.js b/packages/sdk/benchmarks/exampl diff --git a/packages/sdk/scripts/run-benchmarks.js b/packages/sdk/scripts/run-benchmarks.js +++ b/packages/sdk/scripts/run-benchmarks.js @@ -0,0 +1,12 @@ +diff --git a/packages/content/lch/scripts/regenerate-brc170-vectors.mjs b/packages/content/lch/scripts/regenerate-brc170-vectors.mjs ++++ b/packages/content/lch/scripts/regenerate-brc170-vectors.mjs +@@ -0,0 +1,220 @@ diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/StorageIdbSchema.ts b/packages/wallet/wallet-toolbox/src/storage/schema/StorageIdbSchema.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/StorageIdbSchema.ts @@ -0,0 +1,12 @@ @@ -121,6 +124,12 @@ diff --git a/packages/network/chirp/src/index.ts b/packages/network/chirp/src/in diff --git a/packages/network/chirp/src/types.ts b/packages/network/chirp/src/types.ts +++ b/packages/network/chirp/src/types.ts @@ -0,0 +1,12 @@ +diff --git a/packages/content/lch/src/index.ts b/packages/content/lch/src/index.ts ++++ b/packages/content/lch/src/index.ts +@@ -0,0 +1,22 @@ +diff --git a/packages/content/lch/src/types.ts b/packages/content/lch/src/types.ts ++++ b/packages/content/lch/src/types.ts +@@ -0,0 +1,140 @@ diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/Api/BulkFileDataCacheApi.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/Api/BulkFileDataCacheApi.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/Api/BulkFileDataCacheApi.ts @@ -0,0 +1,12 @@ diff --git a/scripts/repository-health.test.mjs b/scripts/repository-health.test.mjs index 893a44308..67637a3b6 100644 --- a/scripts/repository-health.test.mjs +++ b/scripts/repository-health.test.mjs @@ -40,11 +40,11 @@ test('lint exclusion parsing rejects authored tests and benchmarks without backt ) }) -test('workspace discovery exactly matches the 39-project registry', () => { +test('workspace discovery exactly matches the 41-project registry', () => { const discovered = discoverWorkspaceProjects() - assert.equal(discovered.length, 39) - assert.equal(discovered.filter(project => project.manifest.private !== true).length, 32) + assert.equal(discovered.length, 41) + assert.equal(discovered.filter(project => project.manifest.private !== true).length, 33) assert.deepEqual( discovered.map(project => project.path), [...projects.projects].map(project => project.path).sort() @@ -67,7 +67,7 @@ test('workspace discovery exactly matches the 39-project registry', () => { test('every checked-in first-party package manifest uses the current Association name', () => { const manifests = discoverPackageManifests() - assert.equal(manifests.length, 48) + assert.equal(manifests.length, 50) assert.deepEqual(validatePackageAuthorIdentity(manifests), []) assert.ok(manifests.every(({ manifest }) => manifest.author === PACKAGE_AUTHOR)) @@ -80,8 +80,8 @@ test('current repository health controls and ratchet are internally consistent', const result = evaluateRepositoryHealth({ today: '2026-08-24' }) assert.deepEqual(result.errors, []) - assert.equal(result.projects.length, 39) - assert.equal(result.publicPackages, 32) + assert.equal(result.projects.length, 41) + assert.equal(result.publicPackages, 33) assert.equal(result.findings.length, 0) }) @@ -228,7 +228,7 @@ test('every public package declares supported runtime and canonical support meta project => project.manifest.private !== true ) - assert.equal(publicPackages.length, 32) + assert.equal(publicPackages.length, 33) for (const project of publicPackages) { assert.equal( project.manifest.engines?.node, @@ -282,7 +282,7 @@ test('every public package declares supported runtime and canonical support meta test('every public package has canonical, machine-verified consumer profiles', () => { const publicProjects = projects.projects.filter(project => project.release === 'npm-oidc') - assert.equal(publicProjects.length, 32) + assert.equal(publicProjects.length, 33) assert.ok(publicProjects.every(project => project.consumerProfiles.length > 0)) assert.deepEqual( [...new Set(publicProjects.flatMap(project => project.consumerProfiles))].sort(), diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 1db40c06a..0532915b1 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 31) - assert.equal(result.summary.propertyPackages, 29) + assert.equal(result.summary.propertySuites, 32) + assert.equal(result.summary.propertyPackages, 30) assert.equal(result.summary.propertyExcludedPackages, 6) - assert.equal(result.summary.propertyClassifiedPackages, 35) - assert.equal(result.summary.mutationTargets, 31) + assert.equal(result.summary.propertyClassifiedPackages, 36) + assert.equal(result.summary.mutationTargets, 32) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/scripts/typescript-toolchain.test.mjs b/scripts/typescript-toolchain.test.mjs index 52998dc11..b561afb1b 100644 --- a/scripts/typescript-toolchain.test.mjs +++ b/scripts/typescript-toolchain.test.mjs @@ -26,7 +26,7 @@ const governedManifest = { test('all tracked TypeScript projects use the governed side-by-side toolchain', () => { const report = inspectTypeScriptToolchain() - assert.equal(report.governed, 45) + assert.equal(report.governed, 47) assert.equal(report.codegen, 1) assert.ok(report.configurations > 100) assert.equal(report.profiles, 9)