@kirkelabs/walletless-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * onboarding.js — low-friction guest onboarding via EPHEMERAL CUSTODIAL accounts.
3
+ *
4
+ * A guest gets a fresh, server-held Algorand account that is tightly scoped and
5
+ * auto-expires. The user signs nothing. This trades self-custody for low friction,
6
+ * so it is handled defensively:
7
+ * - The secret key is generated from a CSPRNG (oaa-agent-kit LocalOwnerSigner)
8
+ * and attached NON-ENUMERABLY, so it never lands in JSON.stringify, logs,
9
+ * receipts, events, or ledger snapshots. JS cannot wipe key memory — these are
10
+ * DEV/TestNet-grade; production custody needs your own KMS/HSM (see LEGAL.md).
11
+ * - Expiry is ROUND-RELATIVE (live round + ttlRounds), never a hard-coded round
12
+ * (a static default silently expires — a footgun we will not repeat).
13
+ * - Authority can be bounded by composing an oaa-agent-kit mandate, so a leaked
14
+ * ephemeral key is loss-limited (per-tx cap / payee allowlist / expiry).
15
+ */
16
+
17
+ import algosdk from 'algosdk';
18
+ import { LocalOwnerSigner, createMandate } from '@kirkelabs/oaa-agent-kit';
19
+
20
+ const MS_DEFAULT_TTL = 50_000; // ~2 days of rounds on Algorand (~2.8s/round)
21
+
22
+ /**
23
+ * Create an ephemeral custodial account. The returned object is safe to persist
24
+ * (no secret in its enumerable fields); the signer is reachable via the
25
+ * non-enumerable `.signer` for server-side signing only.
26
+ *
27
+ * @param {object} opts
28
+ * @param {algosdk.Algodv2} opts.algod
29
+ * @param {number} [opts.ttlRounds] rounds until expiry (added to the live round)
30
+ * @param {object} [opts.scope] optional authority bound: {perTxMicroAlgos, allowlist?, owner?}
31
+ * @param {string} [opts.network] 'algorand-testnet' (default) | 'algorand'
32
+ */
33
+ export async function createEphemeralAccount({
34
+ algod,
35
+ ttlRounds = MS_DEFAULT_TTL,
36
+ scope = null,
37
+ network = 'algorand-testnet',
38
+ }) {
39
+ if (!Number.isInteger(ttlRounds) || ttlRounds <= 0)
40
+ throw new Error('createEphemeralAccount: ttlRounds must be a positive integer');
41
+ const sp = await algod.getTransactionParams().do();
42
+ const expiryRound = Number(sp.lastValid) + ttlRounds; // round-relative
43
+
44
+ const signer = LocalOwnerSigner.random(); // CSPRNG key, held in-process only
45
+ let mandate = null;
46
+ if (scope && scope.perTxMicroAlgos != null) {
47
+ mandate = createMandate({
48
+ owner: scope.owner ?? signer.address,
49
+ perTxMicroAlgos: scope.perTxMicroAlgos,
50
+ allowlist: scope.allowlist ?? [],
51
+ expiryRound,
52
+ network,
53
+ });
54
+ }
55
+
56
+ const account = {
57
+ address: signer.address,
58
+ network,
59
+ createdAtRound: Number(sp.firstValid),
60
+ expiryRound,
61
+ scope: scope ? { ...scope } : null,
62
+ mandate,
63
+ };
64
+ // The secret signer is NON-ENUMERABLE: it is excluded from JSON/logs/snapshots.
65
+ Object.defineProperty(account, 'signer', { value: signer, enumerable: false });
66
+ return account;
67
+ }
68
+
69
+ /** True if the account has passed its expiry round. */
70
+ export function isExpired(account, currentRound) {
71
+ if (account == null || account.expiryRound == null)
72
+ throw new Error('isExpired: account.expiryRound is required');
73
+ if (currentRound == null) throw new Error('isExpired: currentRound is required');
74
+ return Number(currentRound) > Number(account.expiryRound);
75
+ }
76
+
77
+ /** Sweep the full balance of an ephemeral account to `toAddress` (close-out). */
78
+ async function sweep(algod, fromSigner, toAddress) {
79
+ if (!algosdk.isValidAddress(String(toAddress)))
80
+ throw new Error('sweep: invalid destination address');
81
+ const sp = await algod.getTransactionParams().do();
82
+ const txn = algosdk.makePaymentTxnWithSuggestedParamsFromObject({
83
+ sender: fromSigner.address,
84
+ receiver: toAddress,
85
+ amount: 0,
86
+ closeRemainderTo: toAddress, // close the ephemeral account, sweeping everything
87
+ suggestedParams: sp,
88
+ });
89
+ const [signed] = await fromSigner.signTxns([txn]);
90
+ const { txid } = await algod.sendRawTransaction(signed).do();
91
+ await algosdk.waitForConfirmation(algod, txid, 10);
92
+ return txid;
93
+ }
94
+
95
+ /**
96
+ * Expire an account NOW: sweep its balance back to the operator and abandon it.
97
+ * @returns {Promise<{txid:string, sweptTo:string}>}
98
+ */
99
+ export async function expireAccount({ algod, account, to }) {
100
+ if (!account?.signer) throw new Error('expireAccount: account.signer is required');
101
+ const txid = await sweep(algod, account.signer, to);
102
+ return { txid, sweptTo: String(to) };
103
+ }
104
+
105
+ /**
106
+ * Rotate to a fresh ephemeral account (e.g. on suspected key compromise) and
107
+ * sweep the old balance into it. Inherits scope/network unless overridden.
108
+ * @returns {Promise<{account:object, sweepTxid:string}>}
109
+ */
110
+ export async function rotateAccount({ algod, account, ttlRounds, scope, network }) {
111
+ if (!account?.signer) throw new Error('rotateAccount: account.signer is required');
112
+ const next = await createEphemeralAccount({
113
+ algod,
114
+ ttlRounds: ttlRounds ?? MS_DEFAULT_TTL,
115
+ scope: scope ?? account.scope,
116
+ network: network ?? account.network,
117
+ });
118
+ const sweepTxid = await sweep(algod, account.signer, next.address);
119
+ return { account: next, sweepTxid };
120
+ }
package/src/privacy.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * privacy.js — keep personal data OFF-chain and make erasure real.
3
+ *
4
+ * Design rules (see LEGAL.md, data-protection section):
5
+ * - Personal data (email/phone/name) lives off-chain, encrypted and deletable.
6
+ * - The chain holds only NON-identifying references.
7
+ * - Contact references are KEYED (HMAC with a per-deployment pepper), never a
8
+ * bare hash — a raw SHA-256 of an email/phone is brute-forceable because the
9
+ * input space is small/enumerable. A keyed hash is pseudonymous; it is still
10
+ * personal data under GDPR, but not trivially reversible.
11
+ * - For references that must survive on-chain yet remain erasable, prefer a
12
+ * RANDOM nonce (`pseudonymRef`) stored off-chain over a deterministic hash of
13
+ * the PII: deleting the off-chain mapping then makes the on-chain ref
14
+ * unlinkable. A deterministic PII hash would stay linkable to anyone who
15
+ * re-encounters the same PII, defeating erasure.
16
+ */
17
+
18
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
19
+
20
+ /**
21
+ * Keyed, pseudonymous reference for a contact value (email/phone/etc).
22
+ * Stable for a given (pepper, value) so you can de-duplicate entrants, but not
23
+ * reversible without the pepper. NOT anonymous — still personal data.
24
+ * @param {string} value e.g. an email or phone number
25
+ * @param {string} pepper a secret, per-deployment key (keep out of the repo/chain)
26
+ * @returns {string} hex HMAC-SHA256
27
+ */
28
+ export function hashPii(value, pepper) {
29
+ if (typeof value !== 'string' || value.length === 0)
30
+ throw new Error('hashPii: value must be a non-empty string');
31
+ if (typeof pepper !== 'string' || pepper.length < 16)
32
+ throw new Error('hashPii: pepper must be a secret string of at least 16 chars');
33
+ return createHmac('sha256', pepper).update(value, 'utf8').digest('hex');
34
+ }
35
+
36
+ /** Constant-time comparison of two contact hashes (avoid timing side-channels). */
37
+ export function hashEquals(a, b) {
38
+ const ba = Buffer.from(String(a), 'utf8');
39
+ const bb = Buffer.from(String(b), 'utf8');
40
+ if (ba.length !== bb.length) return false;
41
+ return timingSafeEqual(ba, bb);
42
+ }
43
+
44
+ /**
45
+ * A subject record that maps a RANDOM, erasable on-chain reference to off-chain
46
+ * PII. Put `ref` on-chain (it is just a random nonce); keep `contactHash` and the
47
+ * encrypted PII in your off-chain store. `eraseSubject` deletes everything off
48
+ * chain, after which `ref` is permanently unlinkable.
49
+ *
50
+ * @param {object} opts
51
+ * @param {string} opts.contact raw contact value (NOT stored as-is)
52
+ * @param {string} opts.pepper secret keyed-hash pepper
53
+ * @returns {{ ref: string, contactHash: string, createdRef: true }}
54
+ */
55
+ export function pseudonymRef({ contact, pepper }) {
56
+ const contactHash = hashPii(contact, pepper);
57
+ // Random 16-byte nonce — independent of the PII, so erasing the off-chain map
58
+ // makes it unlinkable. This is what (optionally) goes on-chain.
59
+ const ref = randomBytes(16).toString('hex');
60
+ return { ref, contactHash, createdRef: true };
61
+ }
62
+
63
+ /**
64
+ * Erase a subject from an off-chain store. Removes the encrypted PII and the
65
+ * ref->contact mapping so any on-chain `ref` becomes unlinkable. On-chain
66
+ * receipts/anchors (which hold no PII) intentionally remain.
67
+ *
68
+ * @param {Map|object} store a Map (or plain object) keyed by `ref`
69
+ * @param {string} ref the random reference to erase
70
+ * @returns {{ erased: boolean, ref: string }}
71
+ */
72
+ export function eraseSubject(store, ref) {
73
+ if (ref == null) throw new Error('eraseSubject: ref is required');
74
+ let erased = false;
75
+ if (store instanceof Map) {
76
+ erased = store.delete(ref);
77
+ } else if (store && typeof store === 'object') {
78
+ if (Object.prototype.hasOwnProperty.call(store, ref)) {
79
+ delete store[ref];
80
+ erased = true;
81
+ }
82
+ } else {
83
+ throw new Error('eraseSubject: store must be a Map or object');
84
+ }
85
+ return { erased, ref };
86
+ }
87
+
88
+ /** Assert an object carries no obvious PII before it is written on-chain. */
89
+ const PII_KEYS = /^(email|phone|name|firstname|lastname|address|dob|ip|contact)$/i;
90
+ export function assertNoPii(obj, path = '$') {
91
+ if (obj == null || typeof obj !== 'object') return true;
92
+ for (const [k, v] of Object.entries(obj)) {
93
+ if (PII_KEYS.test(k))
94
+ throw new Error(`assertNoPii: possible PII field "${k}" at ${path} must not go on-chain`);
95
+ if (v && typeof v === 'object') assertNoPii(v, `${path}.${k}`);
96
+ }
97
+ return true;
98
+ }
package/src/receipt.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * receipt.js — receipt-only on-chain proofs. The user signs NOTHING.
3
+ *
4
+ * An order receipt is a compact, NON-PII record that hash-chains to the previous
5
+ * one (tamper-evident) and can be signed by the operator. Only the receipt HASH
6
+ * is attested on-chain — never the body, never any personal data. x402-charged
7
+ * actions go through oaa-agent-kit's hardened `payAndFetch` (SSRF guard, timeout,
8
+ * response-size cap, redirect:error, allowedHosts, confirm hook).
9
+ */
10
+
11
+ import algosdk from 'algosdk';
12
+ import {
13
+ createReceiptId,
14
+ hashCanonicalJson,
15
+ signReceipt as coreSignReceipt,
16
+ verifyReceiptSignature,
17
+ } from '@kirkelabs/open-agent-access-core';
18
+ import { payAndFetch, makeAlgorandPayer } from '@kirkelabs/oaa-agent-kit';
19
+ import { assertNoPii } from './privacy.js';
20
+
21
+ /** Canonical hash payload: the receipt without its own hash/signature fields. */
22
+ function hashPayload(r) {
23
+ const c = { ...r };
24
+ delete c.receiptHash;
25
+ delete c.signature;
26
+ return c;
27
+ }
28
+
29
+ /** A deterministic, reproducible order id from a seed (no randomness/clock). */
30
+ export function deterministicOrderId(seed) {
31
+ return `ord_${hashCanonicalJson(seed).slice(0, 24)}`;
32
+ }
33
+
34
+ /**
35
+ * Build a non-PII, hash-chained order receipt. Throws if any field looks like PII
36
+ * (so a receipt can never carry personal data on-chain). Pass `previousHash`
37
+ * (the prior receipt's `receiptHash`) to chain.
38
+ */
39
+ export function buildOrderReceipt(input = {}) {
40
+ // Reject PII on the RAW input (before we drop unknown keys), so a caller can
41
+ // never smuggle personal data into anything that may be attested on-chain.
42
+ assertNoPii(input);
43
+ const {
44
+ orderId,
45
+ action,
46
+ quantity,
47
+ price,
48
+ agent,
49
+ previousHash = null,
50
+ receiptId,
51
+ timestamp,
52
+ } = input;
53
+ if (orderId == null || action == null) throw new Error('buildOrderReceipt: orderId and action are required');
54
+ const receipt = {
55
+ receiptVersion: '0.1',
56
+ receiptType: 'walletless_order',
57
+ receiptId: receiptId ?? createReceiptId(),
58
+ orderId: String(orderId),
59
+ action: String(action),
60
+ quantity: quantity == null ? null : Number(quantity),
61
+ price: price == null ? null : String(price), // string/integer — never a float literal
62
+ agent: agent == null ? null : String(agent),
63
+ previousHash,
64
+ timestamp: timestamp ?? new Date().toISOString(),
65
+ };
66
+ assertNoPii(receipt);
67
+ receipt.receiptHash = hashCanonicalJson(hashPayload(receipt));
68
+ return receipt;
69
+ }
70
+
71
+ /** Operator-sign a receipt (ed25519, via core). */
72
+ export function signReceipt(receipt, { privateKeyPem, publicKeyPem }) {
73
+ return coreSignReceipt(receipt, privateKeyPem, publicKeyPem);
74
+ }
75
+
76
+ /**
77
+ * Verify a chain of receipts: each links to the previous, each hash recomputes,
78
+ * and any signature present is valid. Robust to malformed input (never throws).
79
+ * @returns {{ok:boolean, count:number, errors:string[]}}
80
+ */
81
+ export function verifyReceiptChain(receipts) {
82
+ const errors = [];
83
+ if (!Array.isArray(receipts)) return { ok: false, count: 0, errors: ['not_an_array'] };
84
+ let prev = null;
85
+ receipts.forEach((r, i) => {
86
+ try {
87
+ if ((r.previousHash ?? null) !== prev) errors.push(`receipt ${i + 1}: previousHash mismatch`);
88
+ if (r.receiptHash !== hashCanonicalJson(hashPayload(r)))
89
+ errors.push(`receipt ${i + 1}: receiptHash mismatch`);
90
+ if (r.signature && !verifyReceiptSignature(r)) errors.push(`receipt ${i + 1}: bad signature`);
91
+ prev = r.receiptHash;
92
+ } catch (e) {
93
+ errors.push(`receipt ${i + 1}: ${e.message}`);
94
+ }
95
+ });
96
+ return { ok: errors.length === 0, count: receipts.length, errors };
97
+ }
98
+
99
+ /**
100
+ * Attest a receipt on-chain as a compact, NON-PII note: only
101
+ * `walletless-receipt:v1:<receiptHash>` (the receipt body stays off-chain).
102
+ * Network-bound (genesis via suggestedParams) and size-bounded (note ≤ 1 KB).
103
+ * @returns {Promise<{txid:string, confirmedRound:number, receiptHash:string}>}
104
+ */
105
+ export async function attestOnChain(algod, signer, receipt) {
106
+ if (!receipt?.receiptHash) throw new Error('attestOnChain: receipt has no receiptHash');
107
+ assertNoPii(receipt);
108
+ const note = new TextEncoder().encode(`walletless-receipt:v1:${receipt.receiptHash}`);
109
+ if (note.length > 1024) throw new Error('attestOnChain: note exceeds 1KB');
110
+ const sp = await algod.getTransactionParams().do();
111
+ const txn = algosdk.makePaymentTxnWithSuggestedParamsFromObject({
112
+ sender: signer.address,
113
+ receiver: signer.address,
114
+ amount: 0,
115
+ note,
116
+ suggestedParams: sp,
117
+ });
118
+ const [signed] = await signer.signTxns([txn]);
119
+ const { txid } = await algod.sendRawTransaction(signed).do();
120
+ const res = await algosdk.waitForConfirmation(algod, txid, 10);
121
+ return {
122
+ txid,
123
+ confirmedRound: Number(res.confirmedRound ?? res['confirmed-round']),
124
+ receiptHash: receipt.receiptHash,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Pay-and-fetch an x402-charged action from a mandate-bound agent account, reusing
130
+ * oaa-agent-kit's hardened client. `fetchPolicy` (allowInsecure, allowedHosts,
131
+ * timeoutMs, maxBytes, maxAmountMicroAlgos, confirm) is forwarded verbatim — set
132
+ * `allowedHosts` when the action URL is not fully trusted.
133
+ */
134
+ export async function chargeForAction({ algod, account, mandate, url, method, body, ...fetchPolicy }) {
135
+ const payer =
136
+ algod && account && mandate ? makeAlgorandPayer({ algod, account, mandate }) : undefined;
137
+ return payAndFetch(url, { payer, method, body, ...fetchPolicy });
138
+ }