diff --git a/README.md b/README.md index ad4838e..4a74b4b 100644 --- a/README.md +++ b/README.md @@ -49,20 +49,27 @@ Planned: ## Quick start ```ts -import { Stack } from '@haverstack/core'; +import { Stack, generateDidKeypair } from '@haverstack/core'; import { LocalAdapter } from '@haverstack/adapter-local'; -// First run — initialize a new stack +// First run — generate an identity keypair. `did` is a "did:key:z6Mk..." +// string derived from the public key — that's your entityId. Persist +// `privateKey` yourself somewhere safe (OS keychain, encrypted file, ...); +// the stack never stores it, only the public identity. +const { did, privateKey } = await generateDidKeypair(); + const adapter = await LocalAdapter.initialize({ path: './my-stack.db', - entityId: 'my-entity-id', + entityId: did, timezone: 'America/New_York', }); // Subsequent runs — open the existing stack // const adapter = await LocalAdapter.open({ path: './my-stack.db' }); -const stack = await Stack.create(adapter); +// ownerProfile creates your own _entity profile record on first run — +// safe to keep passing on every open, it's a no-op once the record exists. +const stack = await Stack.create(adapter, { ownerProfile: { name: 'Jane Smith' } }); // Define a type await stack.defineType('com.example.myapp/note@1', 'Note', { @@ -106,6 +113,14 @@ The fundamental unit of data. Every record has: - **Content** — a JSON object validated against the type's schema - Optional: `parentId`, `entityId`, `appId`, `permissions`, `associations` +### Identity + +`entityId` — on records, permissions, grants, group membership, the stack owner — is a [DID](https://www.w3.org/TR/did-core/) string, e.g. `did:key:z6Mk...`. An identity is a keypair; there's no provider, directory, or domain to trust. `did:key` (a public key, encoded — nothing else) is the mandatory floor; `generateDidKeypair()` mints one. Other DID methods (`did:web`, `did:plc`, ...) are valid `entityId` values too. + +The `_entity` record type is a **local profile** about a DID, not the identity itself — a petname card (`{ did, name, handle? }`) with a display name you chose for that DID. Two stacks can hold different `_entity` cards with different names for the same DID; that's correct, it's each owner's own contact card. `Stack.create(adapter, { ownerProfile })` creates the owner's own card on first run. + +See [Identity](./docs/spec.md#identity) in the spec for the full model, including authentication (challenge–response, not a shared secret) and what's deliberately deferred (key rotation). + ### Types Types define the schema for a record's content. They are identified by a **namespaced, versioned string**: diff --git a/docs/spec.md b/docs/spec.md index 71caaaf..ff9232d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -15,18 +15,24 @@ A **Stack** is a structured, portable personal or organizational data store. It A Stack is created via an async factory that reads identity (and, optionally, timezone) from the adapter. ```ts -// First run — create a new database with initial config +// First run — generate an identity keypair and create a new database. +// `did` is a "did:key:z6Mk..." string — that's the owner entityId. +// Persist `privateKey` yourself (OS keychain, encrypted file, ...); the +// stack never stores it. See Identity below. +const { did, privateKey } = await generateDidKeypair(); const adapter = await LocalAdapter.initialize({ path: './my-stack.db', - entityId: 'abc123', // required — owner entity ID + entityId: did, // required — owner entity ID (a DID) timezone: 'America/New_York', // optional — IANA timezone string, passthrough metadata }); // Subsequent runs — open an existing database const adapter = await LocalAdapter.open({ path: './my-stack.db' }); -// Always the same — reads identity and timezone from the adapter -const stack = await Stack.create(adapter); +// Always the same — reads identity and timezone from the adapter. +// ownerProfile creates the owner's own _entity profile record on first +// run; a no-op on every later call once that record exists. +const stack = await Stack.create(adapter, { ownerProfile: { name: 'Jane Smith' } }); stack.ownerEntityId; // from adapter.ownerEntityId stack.timezone; // from adapter.timezone — string | undefined ``` @@ -44,27 +50,66 @@ Plugin and extension code that doesn't need to know the underlying backend shoul **`_config` is protected** (#67), by `Stack` itself rather than any one adapter — the same layering as schema validation, so every adapter and `ScopedStack` (which delegates to `Stack`) inherit it automatically: - **Addressable only by ID.** `get('_config')` works; a generic `query()` never returns it, regardless of filter — matching every other reserved-ID system record, but load-bearing here since `_config` is read at open and consulted by every permission check. -- **`entityId` is immutable.** `update('_config', { entityId: '...' })` throws `StackConflictError` — changing it would silently re-anchor stack ownership out from under a running system, and the stakes only rise once identity becomes a DID. Other fields (`timezone` today) update normally. `restoreVersion('_config', ...)` inherits the same rule: a snapshot whose `entityId` disagrees with the live record's cannot be restored. Ownership transfer, if it's ever added, is a deliberate future API with key-custody semantics — not a field write. +- **`entityId` is immutable.** `update('_config', { entityId: '...' })` throws `StackConflictError` — changing it would silently re-anchor stack ownership out from under a running system, and the stakes are higher now that identity is a DID: the field isn't a label, it's the key the whole stack answers to. Other fields (`timezone` today) update normally. `restoreVersion('_config', ...)` inherits the same rule: a snapshot whose `entityId` disagrees with the live record's cannot be restored. Ownership transfer, if it's ever added, is a deliberate future API with key-custody semantics — not a field write. - **Never deletable**, soft or hard. `delete('_config')` always throws `StackConflictError`: a soft-deleted config is unreadable through normal paths, and a hard-deleted one leaves nothing to reopen the stack against. --- -### Entity +### Identity -An Entity represents the owner or author of a Stack — a person or organization. Entities are modeled as **Records** of the built-in system type `_entity`, rather than as a separate object type. This means Entities can have attachments (e.g. an avatar), relationships, and all other Record capabilities for free. +Everywhere the system means "who" — `Permission`, `GrantContent.granteeEntityId`, group membership associations, `StackTokenStore`, `record.entityId`, `_config.entityId` — the value is a **DID** ([Decentralized Identifier](https://www.w3.org/TR/did-core/)) string, e.g. `did:key:z6Mk...`. -The Stack has a designated owner Entity, stored as a config value pointing to an `_entity` Record's ID. +**Why DIDs, why no provider.** Stacks are for individuals and small groups with cohesive identity, not a global directory of principals — that scale changes what identity needs to be. It must be _verifiable without a provider_, but doesn't need global discovery infrastructure. Once central providers are ruled out and a domain is undesirable as a hard requirement (a domain is rented identity with a renewal-date failure mode), one primitive remains: cryptographic self-certification. An identity is a keypair; claims are signatures; anyone can verify without asking anyone. -**Content fields:** +**`did:key` is the mandatory floor.** Adopting DID _syntax_, rather than inventing a bespoke identifier format, means not having to pick a winner among self-certifying schemes — the field just needs a `did:` prefix, and every method is distinguishable by it without the data model caring: + +| Method | What it is | Role here | +| --------- | ------------------------------------ | ---------------------------------------------- | +| `did:key` | a public key, encoded — nothing else | **the floor — mandatory to implement** | +| `did:web` | a domain in DID clothing | optional, for those who _want_ domain identity | +| `did:plc` | ATProto's rotation directory | optional, for a future ATProto bridge | + +`@haverstack/core` generates and verifies `did:key` (Ed25519) via `generateDidKeypair()` / `verifyDidSignature()` / etc. (`did.ts`) using Web Crypto only — zero infrastructure, zero resolution, zero registry, no dependency. Other methods are valid `entityId` values but core doesn't mint or resolve them. + +**Key custody is not this library's job.** `generateDidKeypair()` returns a `privateKey`; nothing in `@haverstack/core` or any adapter stores it — only the public DID travels with stack data. Where the private key lives (OS keychain, encrypted file, hardware key) and how it's backed up is an app/UX concern. + +#### Entity + +An Entity represents the owner or author of a Stack — a person or organization. Entities are modeled as **Records** of the built-in system type `_entity`, rather than as a separate object type. This means Entities can have attachments (e.g. an avatar), relationships, and all other Record capabilities for free. + +Crucially, an `_entity` record is a **stack-local profile card about a DID** — not the identity itself: ```ts type EntityContent = { + did: string; // The identity this profile is about, e.g. "did:key:z6Mk..." name: string; // Display name — human-friendly, not necessarily unique. May contain spaces and punctuation. e.g. "Jane Smith" handle?: string; // Short unique identifier — URL-safe, no spaces. e.g. "janesmith". Like a username. Optional for private entities. }; ``` -An Entity record's `entityId` may point to itself (the owner Entity authored its own record). +`name`/`handle` are _this stack owner's_ labels for that DID — the petname pattern (Zooko's triangle: global, human-readable, decentralized — pick two; the escape is names local to the observer). Two stacks holding different `_entity` cards with different display names for the same `did:key:...` is correct behavior: it's each owner's own contact card for that identity. Cross-stack ID collisions are a non-issue mechanically — DIDs are globally unique by construction, unlike a `RecordId` (unique within a stack only; see [Record IDs](#record-ids)). + +The Stack has a designated owner, identified by `_config.entityId` (a DID) — not by pointing at any particular `_entity` record's own `RecordId`. The owner's own `_entity` record (`content.did === ownerEntityId`) is created automatically by `Stack.create(adapter, { ownerProfile })` if one doesn't exist yet — idempotent, safe to pass on every open — closing what was previously a gap where nothing created it. An Entity record's `entityId` (author) may point to itself (the owner Entity authored its own record) but doesn't have to; `Stack.create()`'s bootstrap leaves it unset, matching the "owner-attributed, no `entityId`" convention used elsewhere (see [Attachment](#attachment)). + +#### Groups + +"A group with cohesive identity" is anything that controls a key: a group can be given its own keypair (held by its admins), so it can be granted access, own a collaborative stack (`stackUrl`), and sign as itself. No new machinery beyond what [Group](#group) already describes below — membership associations list member DIDs, same as any other entity reference. Group key generation/custody is deferred; nothing here blocks it. + +#### Authentication: challenge–response + +Token issuance (see [Authentication](#authentication)) stops being an out-of-band secret handoff. Sketch of the handshake a server implements — not a normative wire contract, since the concrete HTTP endpoint lives in server implementations (`@haverstack/core` verifies signatures; it doesn't run a server): + +1. Client requests a nonce for its DID: `POST /auth/challenge { did }` → server responds `{ nonce, expiresAt }`. +2. Client signs the nonce with the private key behind its DID (`signWithDid()`) and sends it back: `POST /auth/token { did, nonce, signature }`. +3. Server verifies (`verifyDidSignature()` — for `did:key` this requires no lookup at all, the public key is decoded from the DID string) and, on success, calls `StackTokenStore.createToken(did)` and returns the bearer token. + +"Access granted to the holder of key X" is verifiable with no provider, no email loop, no OAuth. + +**Verified-but-ungranted is distinguishable from anonymous.** The server always knows which case it's in — verification establishes "this requester controls the key behind this identifier, and will be the same someone next time," which is exactly the line `Permission`/`Grant`'s "any authenticated entity" already depends on. Concretely: anonymous → **401**; verified-but-ungranted → **403** (see [Error responses](#error-responses)). Verified ≠ trusted, or even human — DIDs are free to mint, so this is about stability and accountability of the identifier, not vetting of the person. Default grants remain appropriate only for low-stakes actions; anything of consequence should be granted to specific known DIDs. Servers SHOULD log the requester DID on denied-but-verified requests — actionable signal that plain anonymous noise isn't. + +#### Deferred: key rotation + +With pure `did:key`, identity _is_ the key: lose it and you're a new identity. For individuals and small groups who know each other, that's a recoverable social event ("new key, it's me" over a trusted channel; contacts update their `_entity` cards), not a protocol failure. Rotation — a signed chain of "key A hands off to key B" records, hosted by the stack itself — is a native fit for a future RFC, but nothing here blocks it: a rotated identity is either a new DID _documented by_ that log, or a method upgrade (`did:key` → stack-hosted method) for those who opt in. Multi-device works without rotation in the meantime: the identity key bootstraps a session per device via challenge–response; devices hold revocable tokens, never the key. --- @@ -107,10 +152,12 @@ type GroupContent = { **Membership** is expressed via associations on the `_group` Record, using the existing Association model: ```ts -{ kind: "relationship", label: "member", recordId: "" } -{ kind: "relationship", label: "admin", recordId: "" } +{ kind: "relationship", label: "member", recordId: "" } +{ kind: "relationship", label: "admin", recordId: "" } ``` +(`recordId` here names an Entity by DID, not a Record within the target stack — the field is reused rather than duplicated; see [Identity](#identity).) + This gives roles for free via association labels, and membership is queryable and versioned like any other Record data. There is no role hierarchy beyond this single distinction — matching the scale a Group actually serves (a small, cohesive set of Entities), not a general-purpose permissions system: - **`member`** — counted by group ACLs (`{ access: 'group', groupId, ... }` permission entries, unless the entry names `role: 'admin'`). @@ -136,7 +183,7 @@ A Grant authorises one or more Entities to perform specific actions on Records o type GrantContent = { typeId: TypeId; // Which record type this grant covers actions: GrantAction[]; // Which actions are permitted - granteeEntityId?: string; // Who the grant applies to. Absent = default grant (any authenticated entity). + granteeEntityId?: string; // Who the grant applies to — a DID. Absent = default grant (any authenticated entity). }; type GrantAction = @@ -421,7 +468,7 @@ All Records are **private by default** — readable only by the stack owner. The // Absence of permissions (empty or undefined) = private, owner only. type Permission = | { access: 'public' } - | { access: 'entity'; entityId: string; read: boolean; write: boolean } + | { access: 'entity'; entityId: string; read: boolean; write: boolean } // entityId is a DID | { access: 'group'; groupId: string; role?: 'admin'; read: boolean; write: boolean }; ``` @@ -839,7 +886,7 @@ GET /.well-known/stack ```json { "version": "1.0", - "entityId": "abc123", + "entityId": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", "timezone": "America/New_York", "capabilities": { "fullTextSearch": true, @@ -852,13 +899,13 @@ GET /.well-known/stack ### Authentication -Bearer token in the `Authorization` header. Token issuance is out of scope for the spec — that is the server's concern. The adapter sends the token if configured; the server returns `401` if missing or invalid. +Bearer token in the `Authorization` header. Token issuance itself is out of scope for this spec — that is the server's concern — but _how a token is earned_ now has a shape worth stating: see [Authentication: challenge–response](#authentication-challengeresponse) under [Identity](#identity) for the nonce/signature handshake a server implements before calling `createToken()`. The adapter sends the token if configured; the server returns `401` if missing or invalid, `403` if the requester verified but lacks a grant (see [Error responses](#error-responses)). ``` Authorization: Bearer ``` -(As a non-normative example, `@haverstack/core` defines a `StackTokenStore` contract — `createToken` / `lookupToken` / `listTokens` / `revokeToken` — and `record-adapter-sqlite` ships `NativeTokenStore`, a hashed-token reference implementation in its own file, separate from the records database, for servers that want DB-backed bearer tokens without rolling their own storage. This is optional tooling, not part of the wire protocol; other adapters and servers are free to manage tokens however they like, or not at all.) +(As a non-normative example, `@haverstack/core` defines a `StackTokenStore` contract — `createToken` / `lookupToken` / `listTokens` / `revokeToken` — and `record-adapter-sqlite` ships `NativeTokenStore`, a hashed-token reference implementation in its own file, separate from the records database, for servers that want DB-backed bearer tokens without rolling their own storage. This is optional tooling, not part of the wire protocol; other adapters and servers are free to manage tokens however they like, or not at all. Its `entityId` values are DIDs, same as everywhere else — see [Identity](#identity).) ### Error responses @@ -867,8 +914,8 @@ Standard HTTP status codes are used throughout: | Status | Meaning | When | | ------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **400** | Bad request | `StackQueryError` (code `bad_request`) where the library can identify the malformed input itself — e.g. an undecodable pagination cursor; otherwise a lower-level parse failure (missing required field, invalid JSON) with no core-taxonomy equivalent | -| **401** | Unauthorized | Missing or invalid bearer token | -| **403** | Forbidden | `StackPermissionError` — record exists but the requester lacks access | +| **401** | Unauthorized | Missing or invalid bearer token — no verified DID behind the request at all ("who are you?") | +| **403** | Forbidden | `StackPermissionError` — the requester's DID verified (a valid bearer token identifies them) but they lack access ("your claim is genuine; no") — record exists but permissions/grants don't cover them | | **404** | Not found | `StackNotFoundError` — record or version does not exist | | **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting an attachment still referenced by a record, a client-supplied `id` that already exists, deleting `_config` or changing its `entityId` — see [Stack initialization](#stack-initialization)); or `StackSchemaDriftError` (code `schema_drift`) — `POST /types` redefining an existing `id` with a non-additive schema change (see [Types](#types)) | | **412** | Precondition failed | `StackVersionConflictError` (code `version_conflict`) — an `If-Match` precondition doesn't match the record's current version (see [Versions](#versions)). A distinct error type and status from `StackConflictError`/409, not a subtype of it — the two have different recovery stories | diff --git a/packages/core/README.md b/packages/core/README.md index de92e54..c10e75d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -14,25 +14,32 @@ npm install @haverstack/core You'll also need a storage adapter: -- [`@haverstack/adapter-sqlite`](https://www.npmjs.com/package/@haverstack/adapter-sqlite) — local SQLite storage via sql.js +- [`@haverstack/adapter-local`](https://www.npmjs.com/package/@haverstack/adapter-local) — local storage (native SQLite + disk), single-app/embedded or server use ## Quick start ```ts -import { Stack } from '@haverstack/core'; -import { SQLiteAdapter } from '@haverstack/adapter-sqlite'; +import { Stack, generateDidKeypair } from '@haverstack/core'; +import { LocalAdapter } from '@haverstack/adapter-local'; -// First run — initialize a new stack -const adapter = await SQLiteAdapter.initialize({ +// First run — generate an identity keypair. `did` is a "did:key:z6Mk..." +// string derived from the public key — that's your entityId. Persist +// `privateKey` yourself somewhere safe (OS keychain, encrypted file, ...); +// the stack never stores it, only the public identity. +const { did, privateKey } = await generateDidKeypair(); + +const adapter = await LocalAdapter.initialize({ path: './my-stack.db', - entityId: 'my-entity-id', + entityId: did, timezone: 'America/New_York', }); // Subsequent runs — open the existing stack -// const adapter = await SQLiteAdapter.open({ path: './my-stack.db' }); +// const adapter = await LocalAdapter.open({ path: './my-stack.db' }); -const stack = await Stack.create(adapter); +// ownerProfile creates your own _entity profile record on first run — +// safe to keep passing on every open, it's a no-op once the record exists. +const stack = await Stack.create(adapter, { ownerProfile: { name: 'Jane Smith' } }); // Define a type await stack.defineType('com.example.myapp/note@1', 'Note', { @@ -74,6 +81,10 @@ The fundamental unit of data. Every record has: - **Content** — a JSON object validated against the type's schema - Optional: `parentId`, `entityId`, `appId`, `permissions`, `associations` +### Identity + +`entityId` is a DID string (e.g. `did:key:z6Mk...`) — a keypair, not a name issued by any provider. `generateDidKeypair()` mints the mandatory floor method, `did:key`. `_entity` records are local profile cards _about_ a DID (`{ did, name, handle? }`), not the identity itself — the petname pattern. See [Identity](https://github.com/haverstack/core/blob/main/docs/spec.md#identity) in the spec. + ### Types Types define the schema for a record's content. They are identified by a namespaced, versioned string: diff --git a/packages/core/src/access.ts b/packages/core/src/access.ts index 6ea22a3..a913e10 100644 --- a/packages/core/src/access.ts +++ b/packages/core/src/access.ts @@ -7,7 +7,7 @@ * control; exported standalone for callers that want the raw predicate. */ -import type { Association, RecordId, StackRecord } from './types.js'; +import type { Association, EntityId, RecordId, StackRecord } from './types.js'; export type AccessMode = 'read' | 'write'; @@ -30,8 +30,8 @@ export type RecordResolver = (id: RecordId) => Promise; */ export async function checkAccess( record: StackRecord, - requesterEntityId: string | null, - ownerEntityId: string | null, + requesterEntityId: EntityId | null, + ownerEntityId: EntityId | null, mode: AccessMode, resolveRecord: RecordResolver, ): Promise { @@ -66,7 +66,7 @@ export async function checkAccess( async function resolveGroupRole( groupRecordId: RecordId, - entityId: string, + entityId: EntityId, resolveRecord: RecordResolver, ): Promise { const group = await resolveRecord(groupRecordId); @@ -82,7 +82,7 @@ async function resolveGroupRole( */ export function groupRoleFromAssociations( associations: Association[] | undefined, - entityId: string, + entityId: EntityId, ): GroupRole | null { let role: GroupRole | null = null; for (const a of associations ?? []) { diff --git a/packages/core/src/did.ts b/packages/core/src/did.ts new file mode 100644 index 0000000..7a85952 --- /dev/null +++ b/packages/core/src/did.ts @@ -0,0 +1,198 @@ +/** + * Stack — Decentralized Identifiers + * ------------------------------------------------------- + * entityId values are DID strings (see docs/spec.md § Identity). did:key — + * an Ed25519 public key, multicodec + multibase(base58btc) encoded — is the + * mandatory floor: zero infrastructure, zero resolution, verifiable by + * anyone from the string alone. Other methods (did:web, did:plc, ...) are + * valid entityId values too, but this module only knows how to mint and + * verify did:key. + * + * Uses Web Crypto (crypto.subtle) exclusively — no dependency, works in + * Node (>=22.5, matching the rest of core's floor), browsers, and Deno. + */ + +// ------------------------------------------------------- +// Errors +// ------------------------------------------------------- + +export class InvalidDidError extends Error { + constructor(message = '') { + super(message || 'Invalid DID.'); + this.name = 'InvalidDidError'; + } +} + +// ------------------------------------------------------- +// base58btc (Bitcoin alphabet) — no leading-zero-safe library dependency +// ------------------------------------------------------- + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +const base58btcEncode = (bytes: Uint8Array): string => { + let value = 0n; + for (const byte of bytes) value = (value << 8n) | BigInt(byte); + + let out = ''; + while (value > 0n) { + const remainder = value % 58n; + value /= 58n; + out = BASE58_ALPHABET[Number(remainder)] + out; + } + + for (const byte of bytes) { + if (byte !== 0) break; + out = '1' + out; + } + + return out; +}; + +const base58btcDecode = (encoded: string): Uint8Array => { + let value = 0n; + for (const char of encoded) { + const digit = BASE58_ALPHABET.indexOf(char); + if (digit === -1) { + throw new InvalidDidError(`Invalid base58 character: "${char}"`); + } + value = value * 58n + BigInt(digit); + } + + const bytes: number[] = []; + while (value > 0n) { + bytes.unshift(Number(value & 0xffn)); + value >>= 8n; + } + + for (const char of encoded) { + if (char !== '1') break; + bytes.unshift(0); + } + + return new Uint8Array(bytes); +}; + +// ------------------------------------------------------- +// did:key (Ed25519) +// ------------------------------------------------------- + +/** Multicodec varint prefix for "ed25519-pub" (code 0xed). */ +const ED25519_MULTICODEC_PREFIX = new Uint8Array([0xed, 0x01]); +const ED25519_RAW_KEY_LENGTH = 32; +const DID_KEY_PREFIX = 'did:key:z'; + +export type DidKeypair = { + /** e.g. "did:key:z6Mk..." */ + did: string; + publicKey: CryptoKey; + privateKey: CryptoKey; +}; + +/** Generate a fresh Ed25519 keypair and derive its did:key. */ +export const generateDidKeypair = async (): Promise => { + const { publicKey, privateKey } = (await crypto.subtle.generateKey('Ed25519', true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + return { did: await didFromPublicKey(publicKey), publicKey, privateKey }; +}; + +/** Derive a did:key string from an Ed25519 CryptoKey. */ +export const didFromPublicKey = async (publicKey: CryptoKey): Promise => { + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', publicKey)); + const prefixed = new Uint8Array(ED25519_MULTICODEC_PREFIX.length + raw.length); + prefixed.set(ED25519_MULTICODEC_PREFIX, 0); + prefixed.set(raw, ED25519_MULTICODEC_PREFIX.length); + return DID_KEY_PREFIX + base58btcEncode(prefixed); +}; + +/** + * Parse a did:key string and return its raw Ed25519 public key bytes, or + * null if it isn't a well-formed Ed25519 did:key (wrong prefix, bad base58, + * wrong multicodec, wrong length). + */ +export const parseDidKey = (did: string): Uint8Array | null => { + if (!did.startsWith(DID_KEY_PREFIX)) return null; + + let decoded: Uint8Array; + try { + decoded = base58btcDecode(did.slice(DID_KEY_PREFIX.length)); + } catch { + return null; + } + + const expectedLength = ED25519_MULTICODEC_PREFIX.length + ED25519_RAW_KEY_LENGTH; + if ( + decoded.length !== expectedLength || + decoded[0] !== ED25519_MULTICODEC_PREFIX[0] || + decoded[1] !== ED25519_MULTICODEC_PREFIX[1] + ) { + return null; + } + + return decoded.slice(ED25519_MULTICODEC_PREFIX.length); +}; + +/** Whether a string is a well-formed Ed25519 did:key. */ +export const isValidDidKey = (value: string): boolean => parseDidKey(value) !== null; + +/** Import the Ed25519 public key encoded in a did:key, for verification. */ +export const publicKeyFromDidKey = async (did: string): Promise => { + const raw = parseDidKey(did); + if (!raw) throw new InvalidDidError(`Not a valid did:key: "${did}"`); + return crypto.subtle.importKey('raw', new Uint8Array(raw), 'Ed25519', true, ['verify']); +}; + +// ------------------------------------------------------- +// Generic DID syntax (any method) +// ------------------------------------------------------- + +// did-core ABNF, simplified: "did:" method-name ":" method-specific-id +const GENERIC_DID_FORMAT = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; + +/** + * Whether a string has the generic W3C DID Core syntax (did::) + * — any method, not just did:key. Use isValidDidKey() to additionally + * require the did:key method and a well-formed Ed25519 key. + */ +export const isValidDid = (value: string): boolean => GENERIC_DID_FORMAT.test(value); + +// ------------------------------------------------------- +// Sign / verify +// ------------------------------------------------------- + +export const signWithDid = async (privateKey: CryptoKey, data: Uint8Array): Promise => + new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, new Uint8Array(data))); + +/** + * Verify a signature was produced by the private key behind a did:key. + * Requires no lookup — the public key is decoded from the DID itself. + */ +export const verifyDidSignature = async ( + did: string, + signature: Uint8Array, + data: Uint8Array, +): Promise => { + const publicKey = await publicKeyFromDidKey(did); + return crypto.subtle.verify( + 'Ed25519', + publicKey, + new Uint8Array(signature), + new Uint8Array(data), + ); +}; + +// ------------------------------------------------------- +// Private-key export/import +// ------------------------------------------------------- +// +// Core never stores a private key itself — these exist so callers can +// persist the key material returned by generateDidKeypair() (encrypted key +// backup, OS keychain, etc.) and reconstruct it on a later run. Where that +// key lives is an app/UX concern, not this library's. + +export const exportDidPrivateKeyJwk = async (privateKey: CryptoKey): Promise => + crypto.subtle.exportKey('jwk', privateKey); + +export const importDidPrivateKeyJwk = async (jwk: JsonWebKey): Promise => + crypto.subtle.importKey('jwk', jwk, 'Ed25519', true, ['sign']); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cd22c4c..51530dd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,7 @@ export type { RecordId, TypeId, FileId, + EntityId, AttachmentContent, StackRecord, RecordVersion, @@ -105,6 +106,20 @@ export type { SchemaDriftViolation } from './schema.js'; export { validateContent, isValid } from './validate.js'; export type { ValidationError } from './validate.js'; export { applyMergePatch } from './merge.js'; +export { + InvalidDidError, + generateDidKeypair, + didFromPublicKey, + parseDidKey, + isValidDidKey, + publicKeyFromDidKey, + isValidDid, + signWithDid, + verifyDidSignature, + exportDidPrivateKeyJwk, + importDidPrivateKeyJwk, +} from './did.js'; +export type { DidKeypair } from './did.js'; export { isSafeAttachmentContentType, inferContentTypeFromFilename, diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 48dec57..48e30b1 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -41,6 +41,8 @@ import type { GrantContent, AttachmentContent, ConfigContent, + EntityId, + EntityContent, } from './types.js'; // ------------------------------------------------------- @@ -59,13 +61,23 @@ export type CreateRecordOptions = { */ id?: string; parentId?: string; - entityId?: string; + entityId?: EntityId; appId?: string; permissions?: Permission[]; associations?: Association[]; }; export type StackOptions = { + /** + * Ensures the owner's own `_entity` profile record exists, creating it + * (`did: ownerEntityId`, plus `name`/`handle`) if this is the first + * Stack.create() call against a freshly initialized adapter. No-ops if a + * record with this DID already exists, so it's safe to pass on every + * open, not just the first one — this is what closes the gap where + * nothing used to create the owner's `_entity` record at all. See + * docs/spec.md § Identity. + */ + ownerProfile?: { name: string; handle?: string }; /** * Clock-skew tolerance (ms) for the timestamp-prefix plausibility check * ScopedStack.create() runs on a grantee-supplied `id` — a grantee is an @@ -420,10 +432,35 @@ export class Stack implements StackClient { opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs, ); await stack.seedSystemTypes(); + if (opts.ownerProfile) { + await stack.ensureOwnerEntity(opts.ownerProfile); + } return stack; } - get ownerEntityId(): string { + /** + * Idempotent bootstrap for StackOptions.ownerProfile: creates the owner's + * `_entity` record if none exists yet for their DID. Queries by typeId + * only (a universally-supported native filter) and matches `content.did` + * in memory, rather than relying on RecordFilter.content — which is + * capability-gated and not every adapter implements. `_entity` records + * are stack-local petname cards, not a global directory, so the result + * set here stays small by design (see docs/spec.md § Identity). + */ + private async ensureOwnerEntity(profile: { name: string; handle?: string }): Promise { + const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`; + const { records } = await this.adapter.queryRecords({ filter: { typeId: entityTypeId } }); + const exists = records.some((r) => (r.content as EntityContent).did === this.ownerEntityId); + if (exists) return; + + await this.create(entityTypeId, { + did: this.ownerEntityId, + name: profile.name, + ...(profile.handle && { handle: profile.handle }), + }); + } + + get ownerEntityId(): EntityId { return this.adapter.ownerEntityId; } @@ -447,7 +484,7 @@ export class Stack implements StackClient { * serves requests from multiple, possibly untrusted, entities (e.g. a * multi-tenant API server). */ - asEntity(entityId: string | null): ScopedStack { + asEntity(entityId: EntityId | null): ScopedStack { return new ScopedStack(this, entityId, this.idTimestampSkewMsValue); } @@ -1191,7 +1228,7 @@ export class Stack implements StackClient { * Ownership transfer, if it ever exists, is a deliberate future API with * key-custody semantics (#49), not a field write. */ - private checkConfigEntityIdUnchanged(existingEntityId: string, newEntityId: string): void { + private checkConfigEntityIdUnchanged(existingEntityId: EntityId, newEntityId: EntityId): void { if (newEntityId !== existingEntityId) { throw new StackConflictError( 'Cannot change _config.entityId: it defines stack ownership. ' + @@ -1436,7 +1473,7 @@ export class Stack implements StackClient { * The _grant@1 type is defined automatically on first use. */ async grant( - entityId: string | null, + entityId: EntityId | null, grants: Array<{ actions: GrantAction[]; typeId: TypeId }>, ): Promise { const records: StackRecord[] = []; @@ -1460,7 +1497,7 @@ export class Stack implements StackClient { * every default grant — the same resolution ScopedStack's hasGrant() * uses internally. */ - async listGrants(entityId?: string | null): Promise { + async listGrants(entityId?: EntityId | null): Promise { const all = await queryAllPages((q) => this.query(q), { filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` }, }); @@ -1481,7 +1518,7 @@ export class Stack implements StackClient { * undelete a revocation the same as any other write (#59/#61). */ async revoke( - entityId: string | null, + entityId: EntityId | null, grants: Array<{ actions: GrantAction[]; typeId: TypeId }>, ): Promise { const all = await queryAllPages((q) => this.query(q), { @@ -1513,6 +1550,7 @@ export class Stack implements StackClient { timezone: { kind: 'string' }, }); await this.defineType(`${SYSTEM_TYPES.ENTITY}@1`, 'Entity', { + did: { kind: 'string', required: true }, name: { kind: 'string', required: true }, handle: { kind: 'string' }, }); @@ -1637,7 +1675,7 @@ function stampGroupAdmin(associations: Association[] | undefined, creator: strin export class ScopedStack implements StackClient { constructor( private readonly stack: Stack, - private readonly requesterEntityId: string | null, + private readonly requesterEntityId: EntityId | null, private readonly idTimestampSkewMs: number | null, ) {} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1b47898..dc7b81d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -23,6 +23,16 @@ export type TypeId = string; /** Opaque file identifier returned by putAttachment */ export type FileId = string; +/** + * Identifies a "who" — a DID string, e.g. "did:key:z6Mk...". Distinct from + * RecordId: an entity is never a record within *this* stack the way a + * RecordId's uniqueness scope implies — it's a self-certifying identifier + * that means the same thing in every stack. did:key is the mandatory + * floor method (see did.ts); did:web, did:plc, etc. are also valid values + * here. See docs/spec.md § Identity. + */ +export type EntityId = string; + // ------------------------------------------------------- // Associations // ------------------------------------------------------- @@ -58,7 +68,7 @@ export type Association = TagAssociation | AttachmentAssociation | RelationshipA */ export type Permission = | { access: 'public' } - | { access: 'entity'; entityId: RecordId; read: boolean; write: boolean } + | { access: 'entity'; entityId: EntityId; read: boolean; write: boolean } | { access: 'group'; groupId: RecordId; @@ -83,7 +93,7 @@ export type StackRecord = { // Optional native fields parentId?: RecordId; // Parent record (hierarchy/folders) - entityId?: RecordId; // Author entity, if different from stack owner + entityId?: EntityId; // Author entity, if different from stack owner appId?: RecordId; // App that created this record deletedAt?: Date; // Present if soft-deleted permissions?: Permission[]; @@ -100,7 +110,7 @@ export type RecordVersion = { typeId: TypeId; content: Record; updatedAt: Date; - entityId?: RecordId; // Who made this change + entityId?: EntityId; // Who made this change associations?: Association[]; permissions?: Permission[]; }; @@ -162,8 +172,17 @@ export type StackType = { // System type content shapes // ------------------------------------------------------- -/** Content for _entity records */ +/** + * Content for _entity records. An _entity record is a stack-local profile + * card *about* a DID — not the identity itself, the identity is the `did` + * value. `name`/`handle` are this stack owner's local labels for that DID + * (the petname pattern) — two stacks may hold different `_entity` cards + * with different display names for the same DID, and that's correct: it's + * each owner's own contact card for that identity. + */ export type EntityContent = { + /** The identity this profile is about. e.g. "did:key:z6Mk..." */ + did: string; /** Display name — human-friendly, not necessarily unique. May contain spaces and punctuation. e.g. "Jane Smith" */ name: string; /** Short unique identifier within a namespace — URL-safe, no spaces. e.g. "janesmith". Like a username. Optional for private entities. */ @@ -209,7 +228,7 @@ export type GrantContent = { /** Which actions are permitted. */ actions: GrantAction[]; /** Who the grant applies to. Absent = default grant, applies to any authenticated entity. */ - granteeEntityId?: string; + granteeEntityId?: EntityId; }; /** Content for _attachment records — one per upload, tracks file metadata. */ @@ -226,8 +245,11 @@ export type AttachmentContent = { /** Content for _config records — one singleton per stack, created on initialization. */ export type ConfigContent = { - /** Entity ID of the stack owner. */ - entityId: string; + /** + * DID of the stack owner. Immutable — see docs/spec.md § Stack + * initialization for why ownership transfer isn't a field write. + */ + entityId: EntityId; /** * IANA timezone string e.g. "America/New_York". Optional passthrough app * metadata — nothing in core reads it for behavior. Absent means unset; @@ -272,7 +294,7 @@ export type RecordFilter = { baseId?: string | string[]; parentId?: RecordId | null; // null = root records only appId?: RecordId | RecordId[]; - entityId?: RecordId | RecordId[]; + entityId?: EntityId | EntityId[]; createdAt?: DateRange; updatedAt?: DateRange; @@ -384,8 +406,8 @@ export type ExpectedVersionOptions = { export interface StackRecordAdapter { readonly capabilities: AdapterCapabilities; - /** Entity ID of the stack owner. Set during adapter initialization. */ - readonly ownerEntityId: string; + /** DID of the stack owner. Set during adapter initialization. */ + readonly ownerEntityId: EntityId; /** * IANA timezone string for this stack e.g. "America/New_York", or * undefined if never set. Passthrough app metadata — no core behavior @@ -521,7 +543,7 @@ export type StackAdapter = StackRecordAdapter & StackBlobAdapter; export type TokenInfo = { id: string; - entityId: string; + entityId: EntityId; label?: string; createdAt: Date; expiresAt?: Date; @@ -535,6 +557,14 @@ export type TokenInfo = { * accept storage and tokens as separate parts (`{ adapter, tokens }`) * rather than sniffing an adapter for token methods. * + * `createToken(entityId)` trusts its caller about who that DID is — + * verifying that the caller actually controls the private key behind it + * is the server's job, done once, before calling createToken(), via a + * challenge-response handshake (server nonce, signed by the requester's + * key, verified with verifyDidSignature() — see docs/spec.md § + * Authentication). This interface doesn't change shape for that; it's + * where issuance lands once verification has already happened. + * * Token storage is deliberately decoupled from record storage: the * portable stack file is "your data, take it with you," and auth * material shouldn't travel with it (an export/backup shouldn't also @@ -544,13 +574,13 @@ export type TokenInfo = { */ export interface StackTokenStore { createToken( - entityId: string, + entityId: EntityId, opts?: { label?: string; expiresAt?: Date }, ): Promise<{ id: string; token: string; }>; - lookupToken(token: string): Promise<{ entityId: string } | null>; + lookupToken(token: string): Promise<{ entityId: EntityId } | null>; listTokens(): Promise; revokeToken(id: string): Promise; } diff --git a/packages/core/tests/did.test.ts b/packages/core/tests/did.test.ts new file mode 100644 index 0000000..ae4028f --- /dev/null +++ b/packages/core/tests/did.test.ts @@ -0,0 +1,130 @@ +import { describe, test, expect } from 'vitest'; +import { + generateDidKeypair, + didFromPublicKey, + parseDidKey, + isValidDidKey, + isValidDid, + publicKeyFromDidKey, + signWithDid, + verifyDidSignature, + exportDidPrivateKeyJwk, + importDidPrivateKeyJwk, + InvalidDidError, +} from '../src/did.js'; + +describe('generateDidKeypair', () => { + test('produces a well-formed did:key string', async () => { + const { did } = await generateDidKeypair(); + expect(did.startsWith('did:key:z')).toBe(true); + expect(isValidDidKey(did)).toBe(true); + }); + + test('produces distinct DIDs on each call', async () => { + const a = await generateDidKeypair(); + const b = await generateDidKeypair(); + expect(a.did).not.toBe(b.did); + }); + + test('didFromPublicKey matches the DID returned by generateDidKeypair', async () => { + const { did, publicKey } = await generateDidKeypair(); + expect(await didFromPublicKey(publicKey)).toBe(did); + }); +}); + +describe('parseDidKey / isValidDidKey', () => { + test('round-trips a generated DID back to its 32-byte raw public key', async () => { + const { did, publicKey } = await generateDidKeypair(); + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', publicKey)); + expect(parseDidKey(did)).toEqual(raw); + }); + + test('accepts the well-known W3C did:key Ed25519 example', () => { + // https://www.w3.org/TR/did-key/#example-ed25519-x25519-example + const did = 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'; + expect(isValidDidKey(did)).toBe(true); + expect(parseDidKey(did)).toHaveLength(32); + }); + + test('rejects non-did:key input', () => { + expect(parseDidKey('did:web:example.com')).toBeNull(); + expect(parseDidKey('not-a-did')).toBeNull(); + expect(parseDidKey('')).toBeNull(); + expect(isValidDidKey('did:web:example.com')).toBe(false); + }); + + test('rejects invalid base58 in the key portion', () => { + expect(parseDidKey('did:key:z0OIl')).toBeNull(); + }); + + test('rejects a did:key whose decoded bytes are the wrong length', () => { + // Valid base58btc, but far too short to be a multicodec-prefixed Ed25519 key. + expect(parseDidKey('did:key:z6Mk')).toBeNull(); + }); +}); + +describe('isValidDid', () => { + test('accepts generic DID syntax across methods', () => { + expect(isValidDid('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK')).toBe(true); + expect(isValidDid('did:web:example.com')).toBe(true); + expect(isValidDid('did:plc:abc123')).toBe(true); + }); + + test('rejects malformed strings', () => { + expect(isValidDid('not-a-did')).toBe(false); + expect(isValidDid('did:')).toBe(false); + expect(isValidDid('did:key:')).toBe(false); + expect(isValidDid('')).toBe(false); + }); +}); + +describe('publicKeyFromDidKey', () => { + test('imports a usable verification key', async () => { + const { did, privateKey } = await generateDidKeypair(); + const publicKey = await publicKeyFromDidKey(did); + const data = new TextEncoder().encode('hello'); + const sig = await signWithDid(privateKey, data); + expect(await crypto.subtle.verify('Ed25519', publicKey, new Uint8Array(sig), data)).toBe(true); + }); + + test('throws InvalidDidError for a malformed did:key', async () => { + await expect(publicKeyFromDidKey('did:key:not-valid')).rejects.toThrow(InvalidDidError); + }); +}); + +describe('signWithDid / verifyDidSignature', () => { + test('verifies a signature produced by the matching private key', async () => { + const { did, privateKey } = await generateDidKeypair(); + const data = new TextEncoder().encode('a nonce to sign'); + const signature = await signWithDid(privateKey, data); + expect(await verifyDidSignature(did, signature, data)).toBe(true); + }); + + test('rejects a signature over different data', async () => { + const { did, privateKey } = await generateDidKeypair(); + const signature = await signWithDid(privateKey, new TextEncoder().encode('original')); + expect(await verifyDidSignature(did, signature, new TextEncoder().encode('tampered'))).toBe( + false, + ); + }); + + test('rejects a signature from a different keypair', async () => { + const a = await generateDidKeypair(); + const b = await generateDidKeypair(); + const data = new TextEncoder().encode('a nonce to sign'); + const signature = await signWithDid(a.privateKey, data); + expect(await verifyDidSignature(b.did, signature, data)).toBe(false); + }); +}); + +describe('private key JWK export/import', () => { + test('round-trips a private key so it can still sign', async () => { + const { did, privateKey } = await generateDidKeypair(); + const jwk = await exportDidPrivateKeyJwk(privateKey); + const restored = await importDidPrivateKeyJwk(jwk); + + const data = new TextEncoder().encode('persisted across a restart'); + const signature = await signWithDid(restored, data); + expect(await verifyDidSignature(did, signature, data)).toBe(true); + }); +}); diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index ddd9792..3c815a9 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -65,6 +65,64 @@ describe('Stack.create', () => { const s = await Stack.create(adapter); expect(s.timezone).toBeUndefined(); }); + + describe('ownerProfile', () => { + test('does nothing when omitted', async () => { + const emptyAdapter = new MemoryAdapter({ ownerEntityId: 'did:key:owner' }); + const s = await Stack.create(emptyAdapter); + const { records } = await s.query({ filter: { typeId: '_entity@1' } }); + expect(records).toHaveLength(0); + }); + + test('creates the owner _entity record on first init', async () => { + const emptyAdapter = new MemoryAdapter({ ownerEntityId: 'did:key:owner' }); + const s = await Stack.create(emptyAdapter, { + ownerProfile: { name: 'Jane Smith', handle: 'janesmith' }, + }); + + const { records } = await s.query({ filter: { typeId: '_entity@1' } }); + expect(records).toHaveLength(1); + expect(records[0].content).toEqual({ + did: 'did:key:owner', + name: 'Jane Smith', + handle: 'janesmith', + }); + }); + + test('omits handle when not provided', async () => { + const emptyAdapter = new MemoryAdapter({ ownerEntityId: 'did:key:owner' }); + const s = await Stack.create(emptyAdapter, { ownerProfile: { name: 'Jane Smith' } }); + const { records } = await s.query({ filter: { typeId: '_entity@1' } }); + expect(records[0].content).toEqual({ did: 'did:key:owner', name: 'Jane Smith' }); + }); + + test('is idempotent across reopen — does not duplicate the owner record', async () => { + const emptyAdapter = new MemoryAdapter({ ownerEntityId: 'did:key:owner' }); + await Stack.create(emptyAdapter, { ownerProfile: { name: 'Jane Smith' } }); + // Simulate a later run against the same (still-open) adapter/data. + const reopened = await Stack.create(emptyAdapter, { ownerProfile: { name: 'Jane Smith' } }); + + const { records } = await reopened.query({ filter: { typeId: '_entity@1' } }); + expect(records).toHaveLength(1); + }); + + test('does not overwrite an existing owner record with different content', async () => { + const emptyAdapter = new MemoryAdapter({ ownerEntityId: 'did:key:owner' }); + await Stack.create(emptyAdapter, { ownerProfile: { name: 'Original Name' } }); + const reopened = await Stack.create(emptyAdapter, { ownerProfile: { name: 'New Name' } }); + + const { records } = await reopened.query({ filter: { typeId: '_entity@1' } }); + expect(records).toHaveLength(1); + expect(records[0].content).toMatchObject({ name: 'Original Name' }); + }); + + test('leaves the created record unauthored (no entityId), matching owner-attributed convention', async () => { + const emptyAdapter = new MemoryAdapter({ ownerEntityId: 'did:key:owner' }); + const s = await Stack.create(emptyAdapter, { ownerProfile: { name: 'Jane Smith' } }); + const { records } = await s.query({ filter: { typeId: '_entity@1' } }); + expect(records[0].entityId).toBeUndefined(); + }); + }); }); // -------------------------------------------------------