Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions packages/access-client/src/agent-use-cases.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export async function authorizeWaitAndClaim(accessAgent, email, opts) {
*
* @param {AccessAgent} access
* @param {AgentData} agentData
* @param {string} email
* @param {API.AccountDID} accountId - Account DID (did:mailto or did:plc)
* @param {object} [opts]
* @param {AbortSignal} [opts.signal]
* @param {API.DID<'key'>} [opts.space]
Expand All @@ -232,7 +232,7 @@ export async function authorizeWaitAndClaim(accessAgent, email, opts) {
export async function addProviderAndDelegateToAccount(
access,
agentData,
email,
accountId,
opts
) {
const space = opts?.space || access.currentSpace()
Expand All @@ -257,7 +257,8 @@ export async function addProviderAndDelegateToAccount(
if (spaceMeta) {
throw new Error('Space already registered with storacha.network.')
}
const account = { did: () => DidMailto.fromEmail(DidMailto.email(email)) }

const account = { did: () => accountId }
await addProvider({ access, space, account, provider })
const delegateSpaceAccessResult = await delegateSpaceAccessToAccount(
access,
Expand Down
4 changes: 2 additions & 2 deletions packages/access-client/src/space.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const toMnemonic = ({ signer }) => {

/**
* Creates a (UCAN) delegation that gives full access to the space to the
* specified `account`. At the moment we only allow `did:mailto` principal
* specified `account`. At the moment we allow `did:mailto` and `did:plc` principal
* to be used as an `account`.
*
* @template {Record<string, any>} [S=API.Service]
Expand Down Expand Up @@ -203,7 +203,7 @@ export class OwnedSpace {

/**
* Creates a (UCAN) delegation that gives full access to the space to the
* specified `account`. At the moment we only allow `did:mailto` principal
* specified `account`. At the moment we allow `did:mailto` and `did:plc` principal
* to be used as an `account`.
*
* @param {API.AccountDID} account
Expand Down
15 changes: 13 additions & 2 deletions packages/capabilities/src/access.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { capability, URI, DID, Schema, fail, ok } from '@ucanto/validator'
import * as Types from '@ucanto/interface'
import { attest } from './ucan.js'
import { equalWith, equal, and, SpaceDID, checkLink } from './utils.js'
import { equalWith, equal, and, SpaceDID, checkLink, PlcDID } from './utils.js'
export { top } from './top.js'

/**
Expand All @@ -22,7 +22,7 @@ export const session = attest
/**
* Account identifier.
*/
export const Account = DID.match({ method: 'mailto' })
export const Account = DID.match({ method: 'mailto' }).or(PlcDID)

/**
* Describes the capability requested.
Expand Down Expand Up @@ -115,6 +115,17 @@ export const claim = capability({
with: DID.match({ method: 'key' }).or(DID.match({ method: 'mailto' })),
})

/**
* Capability can be invoked to fetch delegations for a did:plc account
* via public retrieval. This is a public operation that doesn't require
* authentication since delegations are not secrets and only the account owner
* can use them.
*/
export const fetch = capability({
can: 'access/fetch',
with: DID.match({ method: 'plc' }),
})

// https://github.com/storacha/specs/blob/main/w3-access.md#accessdelegate
export const delegate = capability({
can: 'access/delegate',
Expand Down
11 changes: 9 additions & 2 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export type CARLink = Link<unknown, typeof CAR.codec.code>

export type Multihash = Uint8Array

export type AccountDID = DID<'mailto'>
export type AccountDID = DID<'mailto'> | DID<'plc'>
export type SpaceDID = DID<'key'>

/**
Expand Down Expand Up @@ -109,7 +109,14 @@ export interface AccessClaimSuccess {
}
export interface AccessClaimFailure extends Ucanto.Failure {
name: 'AccessClaimFailure'
message: string
}

export type AccessFetch = InferInvokedCapability<typeof AccessCaps.fetch>
export interface AccessFetchSuccess {
delegations: Record<string, Ucanto.ByteView<Ucanto.Delegation>>
}
export interface AccessFetchFailure extends Ucanto.Failure {
name: 'AccessFetchFailure' | 'InvalidDID'
}

export interface AccessConfirmSuccess {
Expand Down
17 changes: 15 additions & 2 deletions packages/capabilities/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@ import { DID, Schema, fail, ok } from '@ucanto/validator'
import { equals } from 'multiformats/bytes'
import { base58btc } from 'multiformats/bases/base58'

// e.g. did:web:storacha.network or did:web:staging.storacha.network
/**
* Example: did:plc:ewvi7nxzyoun6zhxrhs64oiz
*/
export const PlcDID = DID.match({ method: 'plc' })

/**
* Example: did:web:storacha.network or did:web:staging.storacha.network
*/
export const ProviderDID = DID.match({ method: 'web' })

/**
* Example: did:key:z6MkiBeiHFA6kbA2mchg1F9juxCuHuLgymzJpanKswpBZmQT
*/
export const SpaceDID = DID.match({ method: 'key' })

export const AccountDID = DID.match({ method: 'mailto' })
/**
* Example: did:mailto:storacha.network:alice or did:plc:ewvi7nxzyoun6zhxrhs64oiz
*/
export const AccountDID = DID.match({ method: 'mailto' }).or(PlcDID)

export const Await = Schema.struct({
'ucan/await': Schema.tuple([Schema.string(), Schema.link()]),
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/helpers/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export { createContext, cleanupContext }
* @param {UcantoServerTestContext} context
* @param {object} input
* @param {API.DIDKey} input.space
* @param {API.DID<'mailto'>} input.account
* @param {API.DID<'mailto'> | API.DID<'plc'>} input.account
* @param {API.DID<'web'>} input.provider
*/
export const provisionSpace = async (context, { space, account, provider }) => {
Expand Down
72 changes: 72 additions & 0 deletions packages/did-plc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# @storacha/did-plc

Universal utilities for working with the **`did:plc`** method (Node & browser).

## Features

- Resolve a `did:plc` to its DID-Document via the public PLC directory.
- `PlcClient.verifyOwnership` – verify that an arbitrary message was signed by the **current owner** of a `did:plc`.
- `parseDidPlc` – lightweight validator / canonicaliser for `did:plc` strings.
- Works everywhere (`fetch` polyfilled for Node, WebCrypto for signature checks).

## Install

```bash
pnpm add @storacha/did-plc
```

## API

### `PlcClient`

```ts
import { PlcClient } from '@storacha/did-plc'

const client = new PlcClient() // optionally: new PlcClient({ directoryUrl })
const doc = await client.getDocument('did:plc:ewvi7nxzyoun6zhxrhs64oiz')
```

#### `verifyOwnership(did, message, signature)`

```ts
const ok = await client.verifyOwnership(
'did:plc:ewvi7nxzyoun6zhxrhs64oiz',
'hello world',
'BASE64URL_SIGNATURE' // Ed25519, base64url string
)
```

Returns `true` if **any** verificationMethod in the current DID-Document validates the signature.

### `parseDidPlc(input)`

```ts
import { parseDidPlc } from '@storacha/did-plc'

const did = parseDidPlc(' DID:PLC:EWVI7NXZYOUN6ZHXRHS64OIZ ')
// => 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'
```

Throws if the string is not a valid `did:plc`.

## Examples

```ts
import { PlcClient, parseDidPlc } from '@storacha/did-plc'

const client = new PlcClient()
const did = parseDidPlc('did:plc:ewvi7nxzyoun6zhxrhs64oiz')
const doc = await client.getDocument(did)

// ownership proof (base64url Ed25519 signature)
const ok = await client.verifyOwnership(did, 'hello world', signatureB64Url)
```

---

MIT OR Apache-2.0

## References

- [did-method-plc](https://github.com/did-method-plc/did-method-plc/tree/main)
- [storacha/bluesky-backup-webapp-server plc.ts](https://github.com/storacha/bluesky-backup-webapp-server/blob/main/src/lib/plc.ts)
53 changes: 53 additions & 0 deletions packages/did-plc/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@storacha/did-plc",
"version": "0.1.4",
"description": "Universal resolver for did:plc DIDs (Node & browser)",
"type": "module",
"main": "src/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"!dist/**/*.js.map"
],
"exports": {
".": "./dist/index.js",
"./types": "./dist/types.js"
},
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch --preserveWatchOutput",
"clean": "rm -rf dist *.tsbuildinfo",
"test": "mocha --timeout 10s --require ts-node/register test/**/*.spec.js"
},
"dependencies": {
"@noble/ed25519": "^2.2.3",
"@ucanto/principal": "catalog:",
"base64url": "^3.0.1",
"cross-fetch": "^4.0.0",
"multiformats": "catalog:"
},
"devDependencies": {
"@types/mocha": "^10.0.1",
"@typescript-eslint/eslint-plugin": "catalog:",
"mocha": "^10.2.0",
"ts-node": "^10.9.1",
"typescript": "catalog:"
},
"eslintConfig": {
"extends": [
"@storacha/eslint-config"
],
"env": {
"mocha": true
},
"ignorePatterns": [
"dist",
"coverage",
"src/types.js"
]
},
"engines": {
"node": ">=16.15"
},
"license": "Apache-2.0 OR MIT"
}
79 changes: 79 additions & 0 deletions packages/did-plc/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { universalFetch } from './utils.js'
import * as ed25519 from '@noble/ed25519'
import base64url from 'base64url'
import { base58btc } from 'multiformats/bases/base58'

/**
* PLC Directory Client for did:plc operations.
*/
export class PlcClient {
/**
* @param {Object} [opts]
* @param {string} [opts.directoryUrl] - Base URL for PLC directory
*/
constructor(opts = {}) {
this.directoryUrl = opts.directoryUrl || 'https://plc.directory'
}

/**
* Resolve a did:plc to its DID Document.
*
* @param {import('./types.js').DidPlc} did
* @returns {Promise<import('./types.js').PlcDocument>}
* @throws {Error} If the DID cannot be resolved
*/
async getDocument(did) {
const res = await universalFetch(`${this.directoryUrl}/${encodeURIComponent(did)}`)
if (!res.ok) throw new Error(`Failed to resolve ${did}`)
return await res.json()
}

/**
* Verifies that a message was signed by the current owner of the did:plc.
* It verifies all the verification methods in the DID Document to find at
* least one that matches the signature.
*
* @param {import('./types.js').DidPlc} did - The did:plc identifier.
* @param {Uint8Array|string} message - The message that was signed.
* @param {string} signature - The signature to verify (base64url string).
* @returns {Promise<boolean>} True if valid, false otherwise.
*/
async verifyOwnership(did, message, signature) {
try {
const doc = await this.getDocument(did)
const vms = doc.verificationMethod || []
const sigBytes = base64url.default.toBuffer(signature)
const msgBytes = typeof message === 'string' ? new TextEncoder().encode(message) : message

for (const vm of vms) {
if (!vm.publicKeyMultibase) continue
let pubKey
try {
pubKey = base58btc.decode(vm.publicKeyMultibase)
} catch {
continue
}
if (await ed25519.verify(sigBytes, msgBytes, pubKey)) {
return true
}
}
return false
} catch (e) {
return false
}
}

}

/**
* Parse a string and ensure it is a valid did:plc.
* Returns the canonical form (lower-cased).
*
* @param {string} input
* @returns {import('./types.js').DidPlc}
*/
export function parseDidPlc(input) {
const m = /^did:plc:([a-z0-9]{32})$/i.exec(input.trim())
if (!m) throw new Error(`Invalid did:plc: ${input}`)
return /** @type {const} */ (`did:plc:${m[1].toLowerCase()}`)
}
1 change: 1 addition & 0 deletions packages/did-plc/src/indext.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './types'
28 changes: 28 additions & 0 deletions packages/did-plc/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type DidPlc = `did:plc:${string}`

export interface PlcOperation {
type: 'plc_operation';
verificationMethods: Record<string, string>;
rotationKeys: string[];
alsoKnownAs?: string[];
services?: Record<string, unknown>;
prev?: string | null;
sig: string;
}

export interface PlcDocument {
'@context': string[];
id: DidPlc;
alsoKnownAs?: string[];
verificationMethod?: Array<{
id: string;
type: string;
controller: string;
publicKeyMultibase: string;
}>;
service?: Array<{
id: string;
type: string;
serviceEndpoint: string;
}>;
}
14 changes: 14 additions & 0 deletions packages/did-plc/src/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { fetch } from 'cross-fetch'

/**
* Universal fetch helper for Node and browser
*
* @see https://github.com/lifaon76/cross-fetch
*
* @param {RequestInfo} input
* @param {RequestInit=} init
* @returns {Promise<Response>}
*/
export async function universalFetch(input, init) {
return fetch(input, init)
}
Loading
Loading