sello 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ import { encodeProtectedHeader } from "../cose/protected-header.js";
2
+ import { signReceiptEnvelope } from "../cose/sign1.js";
3
+ import { deriveTokenIdentifiers, sha256 } from "../crypto/identifiers.js";
4
+ import { sealReceiptBody } from "../hpke/receipt.js";
5
+ import {
6
+
7
+ assertCanonicalLogUrl,
8
+ logUrlsEqual,
9
+ } from "../log/canonical-url.js";
10
+ import {
11
+ ZERO_SHA256_DIGEST,
12
+ encodeReceiptBody,
13
+
14
+
15
+ } from "../receipt/body.js";
16
+ import { verifySelloJwsToken } from "../token/jws-profile.js";
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+ export function buildReceipt(input ) {
57
+ assertBytes(input.authorizationTokenBytes, "authorizationTokenBytes");
58
+ assertByteLength(input.ownerHpkePublicKey, 32, "ownerHpkePublicKey");
59
+ assertBytes(input.serviceKid, "serviceKid");
60
+ assertBytes(input.servicePrivateKey, "servicePrivateKey");
61
+ assertBytes(input.actionInputBytes, "actionInputBytes");
62
+
63
+ if (typeof input.serviceIdentifier !== "string" || input.serviceIdentifier.length === 0) {
64
+ throw new TypeError("serviceIdentifier must be a non-empty string");
65
+ }
66
+
67
+ if (typeof input.actionType !== "string" || input.actionType.length === 0) {
68
+ throw new TypeError("actionType must be a non-empty string");
69
+ }
70
+
71
+ const selectedLogUrl = selectOwnerTrustedLog(input.selloLogs, input.logUrl);
72
+ const identifiers = deriveTokenIdentifiers(input.authorizationTokenBytes);
73
+ const receiptBody = {
74
+ "agent-identifier": identifiers.agent_identifier,
75
+ "action-type": input.actionType,
76
+ "action-input-hash": sha256(input.actionInputBytes),
77
+ "action-output-hash":
78
+ input.resultStatus === "denied"
79
+ ? ZERO_SHA256_DIGEST
80
+ : sha256(input.actionOutputBytes ?? new Uint8Array()),
81
+ "result-status": input.resultStatus,
82
+ timestamp: input.timestamp,
83
+ };
84
+ const protectedHeaderBytes = encodeProtectedHeader({
85
+ kid: input.serviceKid,
86
+ sello_token_ref: identifiers.sello_token_ref,
87
+ sello_log_url: selectedLogUrl,
88
+ });
89
+ const payload = sealReceiptBody({
90
+ plaintext: encodeReceiptBody(receiptBody),
91
+ ownerPublicKey: input.ownerHpkePublicKey,
92
+ protectedHeaderBytes,
93
+ serviceIdentifier: input.serviceIdentifier,
94
+ selloTokenRef: identifiers.sello_token_ref,
95
+ });
96
+ const envelope = signReceiptEnvelope({
97
+ protectedHeaderBytes,
98
+ payload,
99
+ servicePrivateKey: input.servicePrivateKey,
100
+ });
101
+
102
+ return {
103
+ receiptBody,
104
+ protectedHeaderBytes,
105
+ envelope,
106
+ };
107
+ }
108
+
109
+ export function createReceipt(input ) {
110
+ const built = buildReceipt({
111
+ ...input,
112
+ logUrl: input.log.logUrl,
113
+ });
114
+ const logEntry = input.log.append(built.envelope, input.timestamp);
115
+
116
+ return {
117
+ ...built,
118
+ logEntry,
119
+ };
120
+ }
121
+
122
+ export function createReceiptFromJwsToken(
123
+ input ,
124
+ ) {
125
+ const verifiedToken = verifySelloJwsToken({
126
+ authorizationToken: input.authorizationToken,
127
+ issuerPublicKey: input.tokenIssuerPublicKey,
128
+ });
129
+ const selloLogs = verifiedToken.selloLogs ?? input.fallbackSelloLogs;
130
+
131
+ return createReceipt({
132
+ ...input,
133
+ authorizationTokenBytes: verifiedToken.authorizationTokenBytes,
134
+ ownerHpkePublicKey: verifiedToken.ownerHpkePublicKey,
135
+ selloLogs: selloLogs ?? [],
136
+ });
137
+ }
138
+
139
+ function selectOwnerTrustedLog(
140
+ selloLogs ,
141
+ candidateLogUrl ,
142
+ ) {
143
+ if (!Array.isArray(selloLogs) || selloLogs.length === 0) {
144
+ throw new TypeError("selloLogs must contain at least one owner-trusted log");
145
+ }
146
+
147
+ const canonicalLogs = selloLogs.map((logUrl) => {
148
+ assertCanonicalLogUrl(logUrl, "selloLogs entry");
149
+ return logUrl;
150
+ });
151
+
152
+ const match = canonicalLogs.find((logUrl) => logUrlsEqual(logUrl, candidateLogUrl));
153
+ if (!match) {
154
+ throw new TypeError("service log must be listed in selloLogs");
155
+ }
156
+
157
+ return match;
158
+ }
159
+
160
+ function assertByteLength(
161
+ value ,
162
+ length ,
163
+ name ,
164
+ ) {
165
+ if (!(value instanceof Uint8Array) || value.byteLength !== length) {
166
+ throw new TypeError(`${name} must be a ${length}-byte Uint8Array`);
167
+ }
168
+ }
169
+
170
+ function assertBytes(value , name ) {
171
+ if (!(value instanceof Uint8Array)) {
172
+ throw new TypeError(`${name} must be a Uint8Array`);
173
+ }
174
+ }
@@ -0,0 +1,174 @@
1
+ import { signEd25519, verifyEd25519Signature } from "../crypto/ed25519.js";
2
+ import { assertCanonicalLogUrl } from "../log/canonical-url.js";
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+ const textEncoder = new TextEncoder();
24
+ const textDecoder = new TextDecoder("utf-8", { fatal: true });
25
+ const BASE64URL_32_BYTE_LENGTH = 43;
26
+
27
+ export function verifySelloJwsToken(
28
+ input ,
29
+ ) {
30
+ const authorizationTokenBytes = normalizeTokenBytes(input.authorizationToken);
31
+ const authorizationToken = textDecoder.decode(authorizationTokenBytes);
32
+ const parts = authorizationToken.split(".");
33
+
34
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
35
+ throw new TypeError("authorization token must be compact JWS");
36
+ }
37
+
38
+ const [encodedProtected, encodedPayload, encodedSignature] = parts;
39
+ const protectedHeader = parseJsonObject(
40
+ base64urlDecode(encodedProtected, "JWS protected header"),
41
+ "JWS protected header",
42
+ );
43
+
44
+ if (protectedHeader.alg !== "EdDSA") {
45
+ throw new TypeError("JWS alg must be EdDSA");
46
+ }
47
+
48
+ if ("crit" in protectedHeader) {
49
+ throw new TypeError("JWS crit is not supported by the v0.1 token profile");
50
+ }
51
+
52
+ const signingInput = textEncoder.encode(`${encodedProtected}.${encodedPayload}`);
53
+ const signature = base64urlDecode(encodedSignature, "JWS signature");
54
+ if (!verifyEd25519Signature(signingInput, signature, input.issuerPublicKey)) {
55
+ throw new TypeError("JWS signature verification failed");
56
+ }
57
+
58
+ const payload = parseJsonObject(
59
+ base64urlDecode(encodedPayload, "JWS payload"),
60
+ "JWS payload",
61
+ );
62
+ const ownerHpkePublicKey = readOwnerHpkePublicKey(payload);
63
+ const selloLogs = readSelloLogs(payload);
64
+
65
+ return {
66
+ authorizationTokenBytes,
67
+ ownerHpkePublicKey,
68
+ ...(selloLogs === undefined ? {} : { selloLogs }),
69
+ protectedHeader,
70
+ payload,
71
+ };
72
+ }
73
+
74
+ export function signSelloJwsToken(input ) {
75
+ const protectedHeader = {
76
+ alg: "EdDSA",
77
+ typ: "JWT",
78
+ ...input.protectedHeader,
79
+ };
80
+ const encodedProtected = base64urlEncode(
81
+ textEncoder.encode(JSON.stringify(protectedHeader)),
82
+ );
83
+ const encodedPayload = base64urlEncode(
84
+ textEncoder.encode(JSON.stringify(input.payload)),
85
+ );
86
+ const signingInput = textEncoder.encode(`${encodedProtected}.${encodedPayload}`);
87
+ const signature = signEd25519(signingInput, input.issuerPrivateKey);
88
+
89
+ return `${encodedProtected}.${encodedPayload}.${base64urlEncode(signature)}`;
90
+ }
91
+
92
+ export function base64urlEncode(bytes ) {
93
+ return Buffer.from(bytes).toString("base64url");
94
+ }
95
+
96
+ function readOwnerHpkePublicKey(payload ) {
97
+ const encoded = payload.owner_hpke_pk;
98
+
99
+ if (typeof encoded !== "string") {
100
+ throw new TypeError("owner_hpke_pk must be a string");
101
+ }
102
+
103
+ if (encoded.length !== BASE64URL_32_BYTE_LENGTH) {
104
+ throw new TypeError("owner_hpke_pk must encode a raw 32-byte X25519 public key");
105
+ }
106
+
107
+ const publicKey = base64urlDecode(encoded, "owner_hpke_pk");
108
+ if (publicKey.byteLength !== 32) {
109
+ throw new TypeError("owner_hpke_pk must encode a raw 32-byte X25519 public key");
110
+ }
111
+
112
+ return publicKey;
113
+ }
114
+
115
+ function readSelloLogs(payload ) {
116
+ const value = payload.sello_logs;
117
+
118
+ if (value === undefined) {
119
+ return undefined;
120
+ }
121
+
122
+ if (!Array.isArray(value)) {
123
+ throw new TypeError("sello_logs must be an array");
124
+ }
125
+
126
+ return value.map((entry) => {
127
+ if (typeof entry !== "string") {
128
+ throw new TypeError("sello_logs entries must be strings");
129
+ }
130
+
131
+ assertCanonicalLogUrl(entry, "sello_logs entry");
132
+ return entry;
133
+ });
134
+ }
135
+
136
+ function normalizeTokenBytes(token ) {
137
+ if (typeof token === "string") {
138
+ if (!/^[\x21-\x7e]+$/.test(token)) {
139
+ throw new TypeError("authorization token must be visible ASCII");
140
+ }
141
+
142
+ return textEncoder.encode(token);
143
+ }
144
+
145
+ if (token instanceof Uint8Array) {
146
+ return new Uint8Array(token);
147
+ }
148
+
149
+ throw new TypeError("authorizationToken must be a string or Uint8Array");
150
+ }
151
+
152
+ function parseJsonObject(bytes , name ) {
153
+ let parsed ;
154
+
155
+ try {
156
+ parsed = JSON.parse(textDecoder.decode(bytes));
157
+ } catch {
158
+ throw new TypeError(`${name} must be UTF-8 JSON`);
159
+ }
160
+
161
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
162
+ throw new TypeError(`${name} must be a JSON object`);
163
+ }
164
+
165
+ return parsed ;
166
+ }
167
+
168
+ function base64urlDecode(value , name ) {
169
+ if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
170
+ throw new TypeError(`${name} must be unpadded base64url`);
171
+ }
172
+
173
+ return Uint8Array.from(Buffer.from(value, "base64url"));
174
+ }
@@ -26,6 +26,7 @@ git clone https://github.com/juanfiguera/sello.git "$tmpdir/sello"
26
26
  cd "$tmpdir/sello"
27
27
  node -v # must be v22.7.0 or newer
28
28
  node --run test
29
+ node --run package:test
29
30
  npm pack --dry-run
30
31
  node --experimental-strip-types src/cli/sello.ts --help
31
32
  node --experimental-strip-types src/cli/sello.ts dev --dry-run
@@ -18,6 +18,23 @@ The service process emits receipts. It does not need the owner private key.
18
18
 
19
19
  ## Local Development
20
20
 
21
+ From a new project, the shortest loop is:
22
+
23
+ ```bash
24
+ # Terminal 1
25
+ npx sello dev
26
+
27
+ # Terminal 2
28
+ npx sello emit-demo
29
+ npx sello actions
30
+ ```
31
+
32
+ To write the tiny emitter file into your project:
33
+
34
+ ```bash
35
+ npx sello init-demo
36
+ ```
37
+
21
38
  Inside this repo, start the local log and action viewer:
22
39
 
23
40
  ```bash
package/package.json CHANGED
@@ -1,22 +1,23 @@
1
1
  {
2
2
  "name": "sello",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Reference implementation of the Sello protocol for service-signed AI agent receipts.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "sideEffects": false,
8
8
  "exports": {
9
- ".": "./src/index.ts",
9
+ ".": "./dist/index.js",
10
10
  "./fixtures/vectors/sello-v0.1.json": "./fixtures/vectors/sello-v0.1.json",
11
11
  "./spec": "./SPEC.md",
12
12
  "./package.json": "./package.json"
13
13
  },
14
14
  "bin": {
15
- "sello": "src/cli/sello.ts",
16
- "sello-bench": "src/cli/bench.ts",
17
- "sello-demo": "src/cli/demo.ts"
15
+ "sello": "dist/cli/sello.js",
16
+ "sello-bench": "dist/cli/bench.js",
17
+ "sello-demo": "dist/cli/demo.js"
18
18
  },
19
19
  "files": [
20
+ "dist",
20
21
  "examples",
21
22
  "src",
22
23
  "fixtures/vectors",
@@ -32,7 +33,10 @@
32
33
  "dev": "node --experimental-strip-types src/cli/sello.ts dev",
33
34
  "example:mcp": "node --experimental-strip-types examples/mcp-tool-server.ts",
34
35
  "example:tool": "node --experimental-strip-types examples/quickstart-tool.ts",
36
+ "build": "node --disable-warning=ExperimentalWarning scripts/build-dist.mjs",
37
+ "package:test": "node scripts/package-smoke.mjs",
35
38
  "pack:check": "npm pack --dry-run",
39
+ "prepack": "node --disable-warning=ExperimentalWarning scripts/build-dist.mjs",
36
40
  "test": "node --test --experimental-strip-types"
37
41
  },
38
42
  "engines": {
package/src/cli/sello.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S node --experimental-strip-types
2
2
 
3
3
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
7
  import { generateEd25519KeyPair } from "../cose/sign1.ts";
@@ -9,6 +9,7 @@ import { deriveTokenIdentifiers, sha256, toHex } from "../crypto/identifiers.ts"
9
9
  import { generateHpkeKeyPair } from "../hpke/receipt.ts";
10
10
  import { type CanonicalLogUrl } from "../log/canonical-url.ts";
11
11
  import { MockTransparencyLog } from "../log/mock-log.ts";
12
+ import { canonicalJsonBytes } from "../mcp/middleware.ts";
12
13
  import { verifyReceipts } from "../owner/verify.ts";
13
14
  import {
14
15
  loadSignedRegistry,
@@ -26,10 +27,12 @@ import {
26
27
  } from "../sdk/keys.ts";
27
28
  import {
28
29
  deserializeEntry,
30
+ http as httpLog,
29
31
  queryHttpLogByTokenRef,
30
32
  serializeEntry,
31
33
  toCanonicalLogUrl,
32
34
  } from "../sdk/logs.ts";
35
+ import { createSelloService } from "../sdk/service.ts";
33
36
 
34
37
  type DevState = {
35
38
  serviceId: string;
@@ -57,6 +60,12 @@ try {
57
60
  case "dev":
58
61
  await devCommand(process.argv.slice(3));
59
62
  break;
63
+ case "emit-demo":
64
+ await emitDemoCommand(process.argv.slice(3));
65
+ break;
66
+ case "init-demo":
67
+ initDemoCommand(process.argv.slice(3));
68
+ break;
60
69
  case "keys":
61
70
  keysCommand(process.argv.slice(3));
62
71
  break;
@@ -157,6 +166,75 @@ async function devCommand(args: string[]): Promise<void> {
157
166
  });
158
167
  }
159
168
 
169
+ async function emitDemoCommand(args: string[]): Promise<void> {
170
+ const state = loadDevStateOrThrow(
171
+ "missing local Sello dev state. Run `sello dev` first, then run `sello emit-demo` in another terminal from the same directory.",
172
+ );
173
+ const title = readFlag(args, "--title") ?? "Test Sello receipt";
174
+ const receipts = createSelloService({
175
+ service: state.serviceId,
176
+ serviceKey: state.serviceKey,
177
+ tokenIssuer: state.tokenIssuerPublicKey,
178
+ log: httpLog(state.logUrl, { endpoint: state.logEndpoint }),
179
+ submit: { mode: "await" },
180
+ });
181
+ const createEvent = receipts.tool<DemoEventRequest, DemoEventResponse>(
182
+ "calendar.create_event",
183
+ async (request) => ({
184
+ id: `evt_${slug(request.title)}`,
185
+ calendarId: request.calendarId,
186
+ title: request.title,
187
+ status: "created",
188
+ createdAt: new Date().toISOString(),
189
+ }),
190
+ {
191
+ canonicalizeInput: (request) => canonicalJsonBytes({
192
+ calendarId: request.calendarId,
193
+ title: request.title,
194
+ start: request.start,
195
+ attendees: request.attendees,
196
+ }),
197
+ },
198
+ );
199
+ const response = await createEvent({
200
+ authorizationToken: state.agentToken,
201
+ calendarId: "demo-calendar",
202
+ title,
203
+ start: "2026-06-05T17:00:00Z",
204
+ attendees: ["ada@example.com", "grace@example.com"],
205
+ });
206
+
207
+ await receipts.flush();
208
+
209
+ console.log("Emitted demo Sello receipt.");
210
+ console.log(JSON.stringify(response, null, 2));
211
+ console.log("");
212
+ console.log("View verified actions with:");
213
+ console.log(" sello actions");
214
+ console.log("");
215
+ console.log("Or open:");
216
+ console.log(` ${actionViewerUrl(state)}`);
217
+ }
218
+
219
+ function initDemoCommand(args: string[]): void {
220
+ const output = readFlag(args, "--output") ?? "emit-receipt.mjs";
221
+ const force = args.includes("--force");
222
+
223
+ if (existsSync(output) && !force) {
224
+ throw new TypeError(`${output} already exists. Pass --force to overwrite it.`);
225
+ }
226
+
227
+ writeFileSync(output, demoEmitterSource(), { mode: 0o644 });
228
+
229
+ console.log(`Created ${output}`);
230
+ console.log("");
231
+ console.log("Run:");
232
+ console.log(" npm install sello");
233
+ console.log(" npx sello dev");
234
+ console.log(` node ${output}`);
235
+ console.log(" npx sello actions");
236
+ }
237
+
160
238
  function keysCommand(args: string[]): void {
161
239
  const subcommand = args[0] ?? "service";
162
240
  if (subcommand !== "service") {
@@ -339,6 +417,14 @@ function loadDevStateIfPresent(): DevState | undefined {
339
417
  }
340
418
  }
341
419
 
420
+ function loadDevStateOrThrow(message: string): DevState {
421
+ const state = loadDevStateIfPresent();
422
+ if (!state) {
423
+ throw new TypeError(message);
424
+ }
425
+ return state;
426
+ }
427
+
342
428
  function devStatePath(): string {
343
429
  return join(process.cwd(), ".sello", "dev.json");
344
430
  }
@@ -478,6 +564,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
478
564
  function printHelp(): void {
479
565
  console.log(`Usage:
480
566
  sello dev [--port 8787] [--service service-id] [--dry-run]
567
+ sello emit-demo [--title title]
568
+ sello init-demo [--output emit-receipt.mjs] [--force]
481
569
  sello actions [--token agent-token]
482
570
  sello keys service
483
571
  sello inspect-env
@@ -514,3 +602,79 @@ function enforceNodeVersion(): void {
514
602
  );
515
603
  }
516
604
  }
605
+
606
+ type DemoEventRequest = {
607
+ authorizationToken: string;
608
+ calendarId: string;
609
+ title: string;
610
+ start: string;
611
+ attendees: string[];
612
+ };
613
+
614
+ type DemoEventResponse = {
615
+ id: string;
616
+ calendarId: string;
617
+ title: string;
618
+ status: "created";
619
+ createdAt: string;
620
+ };
621
+
622
+ function actionViewerUrl(state: DevState): string {
623
+ const endpoint = new URL(state.logEndpoint);
624
+ return `${endpoint.origin}/actions`;
625
+ }
626
+
627
+ function slug(value: string): string {
628
+ return value
629
+ .toLowerCase()
630
+ .replace(/[^a-z0-9]+/g, "_")
631
+ .replace(/^_+|_+$/g, "")
632
+ .slice(0, 40);
633
+ }
634
+
635
+ function demoEmitterSource(): string {
636
+ return `import { readFileSync } from "node:fs";
637
+ import { canonicalJsonBytes, sello } from "sello";
638
+
639
+ const state = JSON.parse(readFileSync(".sello/dev.json", "utf8"));
640
+
641
+ const receipts = sello.service({
642
+ service: state.serviceId,
643
+ serviceKey: state.serviceKey,
644
+ tokenIssuer: state.tokenIssuerPublicKey,
645
+ log: sello.logs.http(state.logUrl, { endpoint: state.logEndpoint }),
646
+ submit: { mode: "await" },
647
+ });
648
+
649
+ const createEvent = receipts.tool(
650
+ "calendar.create_event",
651
+ async (request) => ({
652
+ id: "evt_test_sello_receipt",
653
+ calendarId: request.calendarId,
654
+ title: request.title,
655
+ status: "created",
656
+ createdAt: new Date().toISOString(),
657
+ }),
658
+ {
659
+ canonicalizeInput: (request) =>
660
+ canonicalJsonBytes({
661
+ calendarId: request.calendarId,
662
+ title: request.title,
663
+ start: request.start,
664
+ attendees: request.attendees,
665
+ }),
666
+ },
667
+ );
668
+
669
+ const result = await createEvent({
670
+ authorizationToken: state.agentToken,
671
+ calendarId: "demo-calendar",
672
+ title: "Test Sello receipt",
673
+ start: "2026-06-05T17:00:00Z",
674
+ attendees: ["ada@example.com", "grace@example.com"],
675
+ });
676
+
677
+ await receipts.flush();
678
+ console.log(result);
679
+ `;
680
+ }