Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
57f462c
feat: add holder-bound identity credential wallet
raubrey-stripe Aug 25, 2026
58556a9
fix: defer credential issuer validation
raubrey-stripe Aug 25, 2026
b51668c
fix: keep credential token overrides explicit
raubrey-stripe Aug 25, 2026
e3aa5a9
fix: remove obsolete credential scope guidance
raubrey-stripe Aug 26, 2026
9be1122
chore: drop the AAP acronym from credential-wallet docs and names
raubrey-stripe Aug 27, 2026
0236462
chore: keep credentials unlisted unless LINK_IDENTITY_COMMANDS is set
raubrey-stripe Aug 27, 2026
f2fa207
feat: nest credentials under identity as credentials get
raubrey-stripe Aug 28, 2026
4a18079
refactor: centralize the default identity holder key
raubrey-stripe Sep 2, 2026
ad15d19
feat: issue identity credentials to agent-managed public keys
raubrey-stripe Sep 14, 2026
66f64df
fix: return cached credential resources without optional-field narrowing
raubrey-stripe Sep 14, 2026
dc8304b
fix: align credential issuance with Link trust boundary
raubrey-stripe Sep 17, 2026
820c83b
refactor: simplify identity credential options
raubrey-stripe Sep 17, 2026
17ea92b
refactor: use shared auth for identity credentials
raubrey-stripe Sep 17, 2026
569a11e
test: remove obsolete credential schema assertions
raubrey-stripe Sep 17, 2026
88bda9f
refactor: use only the managed credential key
raubrey-stripe Sep 17, 2026
e515b79
refactor: name identity credential APIs explicitly
raubrey-stripe Sep 17, 2026
9140931
refactor: share identity credential request handling
raubrey-stripe Sep 17, 2026
c471597
refactor: support only Ed25519 holder keys
raubrey-stripe Sep 17, 2026
3534bc4
fix: show saved identity artifacts in terminal
raubrey-stripe Sep 18, 2026
4578aa1
refactor: request identity credentials
raubrey-stripe Sep 18, 2026
6ce7262
refactor: store current identity credential
raubrey-stripe Sep 18, 2026
2c36bbd
fix: address credential storage feedback
raubrey-stripe Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ node packages/cli/dist/cli.js <command>

Defined in `packages/sdk/src/resources/interfaces.ts`:
- `IAttestationsResource` — Privacy Pass Blind RSA token issuance
- `IIdentityCredentialsResource` — signed user info issuance
- `ISpendRequestResource` — CRUD + request-approval for spend requests

The SDK only accepts credentials. Device authorization, refresh-token
Expand All @@ -57,7 +58,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo
- **Interactive** (default): Ink/React components from `packages/cli/src/commands/`
- **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1

Commands: `auth login|logout|status`, `user-info retrieve`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `report`, `serve`.
Commands: `auth login|logout|status`, `user-info retrieve`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `identity credentials request`, `report`, `serve`.

The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur.

Expand Down Expand Up @@ -144,6 +145,17 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT
- `--step` is where the agent was when the outcome occurred (max 500). `--attempt-trace` is the whole path it took, one numbered line per step, intended to be replayable by another agent. Both are optional and independent.
- `--attempt-trace` intentionally carries **no** zod `.max()`. The API truncates at `REPORT_ATTEMPT_TRACE_MAX_LENGTH` (8000, exported from the SDK) and still records the report, so client-side rejection would trade a long narrative for a lost outcome. `--step` and `--freeform-context` keep their `.max(500)` because the API rejects those outright.

### identity credentials command

Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENTITY_COMMANDS=1` (or `true`). Even when enabled, the command sets `mcp: false` so MCP clients do not see it.

`identity credentials request` — requests signed user info proving it comes from Link (a wallet of claims such as name, email, and phone). Human TTY runs save the credential and show its path; structured output returns the credential and holder-key path. The SDK discovers and calls `credential_endpoint`; the CLI owns default holder-key persistence, claim decoding, and command registration under `packages/cli/src/commands/identity/`.

- Discovery uses `GET https://api.link.com/.well-known/aap-issuer`. The metadata issuer must be exactly `https://api.link.com`, and `credential_endpoint` must remain on that HTTPS origin. `LINK_API_BASE_URL` does not change the credential issuer.
- `POST <credential_endpoint>` sends `{"cnf":{"jwk":<public JWK>}}`.
- Issuance uses the Ed25519 holder key at `~/.link/holder-key.jwk` (mode 0600).
- The issued `cnf.jwk` is checked against the requested public key before returning the credential artifact.

### serve command

- `serve [--port <n>] [--host <host>]` — HTTP server that exposes the CLI's MCP endpoint. Implemented in `packages/cli/src/commands/serve/index.ts`. The handler forwards to `rootCli.fetch()` (incur), but is a **privilege boundary**: `requireAuth` only proves the CLI *owner* is authenticated, not that the HTTP caller is authorized.
Expand All @@ -170,6 +182,7 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT
Server-returned strings can contain ANSI escape sequences or control characters that spoof the terminal approval UI. Sanitization is handled automatically via `sanitizeDeep()` from `packages/cli/src/utils/sanitize-text.ts`:

- **SDK-resource data** — sanitized automatically at the `sanitizeResource()` proxy boundary in `packages/cli/src/utils/resource-factory.ts`. All server data flowing through SDK resources (spend-request, payment-methods, sources, etc.) is `sanitizeDeep()`'d before reaching components or the incur formatter, in every output format.
- **Encoded server data decoded by the CLI** — must be sanitized after decoding. Credential issuance sanitizes claims recovered from SD-JWT disclosures in `commands/credentials/issue.ts`; sanitizing the compact credential string at the resource boundary does not sanitize its decoded values.
- **Commands using `useAsyncAction` hook** — sanitized automatically. The hook calls `sanitizeDeep()` on all returned data before it reaches components.
- **Commands with manual state management** (e.g. `create.tsx`, `retrieve.tsx`, `request-approval.tsx`, `mpp/pay.tsx`) — must call `sanitizeDeep()` on API responses before calling `setRequest()`/`setState()`.
- **Attacker-controlled data that does NOT flow through an SDK resource** — must be sanitized at its own parse boundary. `mpp pay` sanitizes the HTTP response in `readPayResult()` (`pay.tsx`); `mpp decode` sanitizes the parsed `WWW-Authenticate` challenge in `decodeStripeChallenge()` (`decode.ts`). These bypass the resource factory, so the return value of the parse/fetch helper is the chokepoint — sanitizing there covers both the interactive Ink render and the agent (toon/yaml/md) output at once.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,22 @@ All commands accept `--auth <path>` to store auth credentials in a specific file

Unlisted commands: set `LINK_IDENTITY_COMMANDS=1` to enable them. They are omitted from `--help`, `--llms`, and MCP tool lists otherwise.

Privacy-preserving tokens that show Link attests to your agent:
**Privacy-preserving tokens** that show Link attests to your agent:

```bash
LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10
```

Attestation tokens can be used to respond to attestation challenges presented by downstream services. Token artifacts are written to `~/.link-cli/attestations`.

**User info that has been signed, proving it comes from Link**:

```bash
LINK_IDENTITY_COMMANDS=1 link-cli identity credentials request
```

`identity credentials request` returns a signed credential bound to the CLI-managed holder key at `~/.link/holder-key.jwk`.

### Spend request lifecycle

A spend request moves through: **create** → **request approval** → **approved** (with credentials).
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ if (identityCommandsEnabled) {
cli.command(
createIdentityCli({
createAttestationsResource: () => factory.createAttestationsResource(),
createIdentityCredentialsResource: () =>
factory.createIdentityCredentialsResource(),
}),
);
}
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/commands/attestations/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { IAttestationsResource } from '@stripe/link-sdk';
import { Cli } from 'incur';
import { renderInteractive } from '../../utils/render-interactive';
import { SavedArtifact } from '../identity/saved-artifact';
import { exportAttestationTokens } from './export';
import { requestOptions } from './schema';
import { writeAttestationArtifact } from './storage';
Expand Down Expand Up @@ -27,12 +29,29 @@ export function createAttestationsCli(
}),
);
const outputFile = await writeAttestationArtifact(artifact);
return {
const result = {
issuer: artifact.issuer,
token_key_id: artifact.token_key_id,
count: artifact.count,
output_file: outputFile,
};

if (
!c.agent &&
!c.formatExplicit &&
!process.argv.includes('--full-output')
) {
return renderInteractive(
<SavedArtifact
message={`Attestation token${artifact.count === 1 ? '' : 's'} saved`}
outputFile={outputFile}
details={[{ label: 'Count', value: artifact.count }]}
/>,
() => result,
);
}

return result;
},
});

Expand Down
139 changes: 139 additions & 0 deletions packages/cli/src/commands/credentials/__tests__/issue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { generateKeyPairSync } from 'node:crypto';
import { existsSync, mkdtempSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type {
HolderPublicJwk,
IIdentityCredentialsResource,
} from '@stripe/link-sdk';
import { holderJwkThumbprint } from '@stripe/link-sdk';
import { describe, expect, it, vi } from 'vitest';
import { loadHolderKey, loadOrCreateHolderKey } from '../holder-key';
import { issueIdentityCredential } from '../issue';

function publicJwkFromPrivate(): HolderPublicJwk {
const privateKey = generateKeyPairSync('ed25519').privateKey;
const jwk = privateKey.export({ format: 'jwk' }) as Record<string, string>;
return { kty: 'OKP', crv: 'Ed25519', x: jwk.x };
}

function encodeSegment(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}

function compactCredential(
jwk: HolderPublicJwk,
claims: Record<string, unknown> = { email: 'user@example.com' },
): string {
const jwt = `${encodeSegment({ alg: 'EdDSA', typ: 'vc+sd-jwt' })}.${encodeSegment(
{
iss: 'https://api.link.com',
cnf: { jwk },
},
)}.sig`;
const disclosures = Object.entries(claims).map(([name, value]) =>
encodeSegment(['salt', name, value]),
);
return `${[jwt, ...disclosures].join('~')}~`;
}

function tempDir(): string {
return mkdtempSync(join(tmpdir(), 'link-credential-'));
}

describe('issueIdentityCredential', () => {
it('issues a managed credential and records the local key path', async () => {
const dir = tempDir();
const keyFile = join(dir, 'holder-key.jwk');
const resource: IIdentityCredentialsResource = {
issue: vi.fn(async ({ cnf }) => ({
credential: compactCredential(cnf.jwk),
issuer: 'https://api.link.com',
expires_at: '2026-09-15T00:00:00Z',
})),
};

const result = await issueIdentityCredential({
resource,
keyFile,
});

expect(existsSync(keyFile)).toBe(true);
expect(result.version).toBe(1);
expect(result.holder.path).toBe(keyFile);
expect(result.holder.created).toBe(true);
expect(result.holder.thumbprint).toBe(
holderJwkThumbprint(result.holder.jwk),
);
expect(result.claims).toEqual({ email: 'user@example.com' });
expect(resource.issue).toHaveBeenCalledWith({
cnf: { jwk: result.holder.jwk },
});
});

it('sanitizes disclosed claims before returning them to the CLI', async () => {
const keyFile = join(tempDir(), 'holder-key.jwk');
const resource: IIdentityCredentialsResource = {
issue: vi.fn(async ({ cnf }) => ({
credential: compactCredential(cnf.jwk, {
email: '\u001b[2Juser@example.com\u0007',
}),
issuer: 'https://api.link.com',
expires_at: '2026-09-15T00:00:00Z',
})),
};

const result = await issueIdentityCredential({
resource,
keyFile,
});

expect(result.claims).toEqual({ email: 'user@example.com' });
});

it('rejects an issued credential whose cnf.jwk does not match', async () => {
const keyFile = join(tempDir(), 'holder-key.jwk');
const other = publicJwkFromPrivate();
const resource: IIdentityCredentialsResource = {
issue: vi.fn(async () => ({
credential: compactCredential(other),
issuer: 'https://api.link.com',
expires_at: '2026-09-15T00:00:00Z',
})),
};

await expect(
issueIdentityCredential({
resource,
keyFile,
}),
).rejects.toThrow('does not match the requested holder public key');
});
});

describe('loadHolderKey', () => {
it('does not generate a replacement key when the file is missing', () => {
const missing = join(tempDir(), 'missing.jwk');
expect(() => loadHolderKey(missing)).toThrow('Holder key not found');
expect(existsSync(missing)).toBe(false);
});

it('loads an existing managed key', () => {
const keyFile = join(tempDir(), 'holder-key.jwk');
const created = loadOrCreateHolderKey(keyFile);
const loaded = loadHolderKey(keyFile);
expect(statSync(keyFile).mode & 0o777).toBe(0o600);
expect(loaded.created).toBe(false);
expect(loaded.publicJwk).toEqual(created.publicJwk);
});

it('refuses to read or write a holder key through a symbolic link', () => {
const dir = tempDir();
const target = join(dir, 'target.jwk');
const keyFile = join(dir, 'holder-key.jwk');
symlinkSync(target, keyFile);

expect(() => loadOrCreateHolderKey(keyFile)).toThrow('symbolic link');
expect(existsSync(target)).toBe(false);
});
});
Loading
Loading