diff --git a/CLAUDE.md b/CLAUDE.md index f9442ce..6fea5d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,15 @@ matter discover ble matter commission code MT:Y3.13OTB00KA0648G00 matter commission ip 192.168.1.42 --setup-code 34970112332 +# Open a fresh commissioning window on an already-commissioned device so a +# second ecosystem (Apple Home, Google Home, Alexa, ...) can commission it +# without a factory reset. Prints a QR + manual pairing code. +matter @1 commission open-window +matter @1 commission open-window --timeout 5m +matter @1 commission open-window --passcode 20202021 --discriminator 3840 +matter @1 commission open-window --basic # Basic Commissioning Method +matter @1 commission close-window # revoke an open window + # List commissioned devices and inspect them matter fabric ls matter @1 tree # show endpoints & clusters @@ -138,3 +147,8 @@ Key rules inline: branch per feature, `mise run lint` + `mise run test` before c 7. **Every public function and type must have a godoc comment.** No exceptions. 8. **Daemon-aware store access** — See **[`docs/DAEMON_STORE.md`](docs/DAEMON_STORE.md)**. Never call `openStore()` or `store.NewBoltStore()` directly in CLI commands; use the helpers in `cli/device.go`. 9. **BLE commissioning network credentials** — See **[`docs/BLE_COMMISSIONING_NETWORK.md`](docs/BLE_COMMISSIONING_NETWORK.md)**. When commissioning over BLE, use the WiFi credentials defined there (`tomkat-iot` / `soviets-ferry-dork`). + + +## Review + +Codex will review all code changes once done with implementation! diff --git a/cli/code.go b/cli/code.go index 50ead84..6f72df3 100644 --- a/cli/code.go +++ b/cli/code.go @@ -117,6 +117,10 @@ func newCodeParseCmd() *cobra.Command { fmt.Fprintf(w, " %s %s %s\n", output.Label("Discriminator:"), output.Bold(fmt.Sprintf("%d", payload.Discriminator)), output.Muted(fmt.Sprintf("(0x%03X)", payload.Discriminator))) fmt.Fprintf(w, " %s %s\n", output.Label("Passcode:"), output.Bold(fmt.Sprintf("%d", payload.Passcode))) fmt.Fprintln(w) + if qr != "" && output.IsTTY() { + output.RenderQRCode(w, qr) + fmt.Fprintln(w) + } fmt.Fprintf(w, " %s %s\n", output.Label("QR Code:"), output.Success(qr)) fmt.Fprintf(w, " %s %s\n", output.Label("Manual Code:"), output.Success(manual)) return nil @@ -176,6 +180,10 @@ func newCodeGenerateCmd() *cobra.Command { } w := cmd.OutOrStdout() + if output.IsTTY() { + output.RenderQRCode(w, qr) + fmt.Fprintln(w) + } fmt.Fprintf(w, " %s %s\n", output.Label("QR Code:"), output.Success(qr)) fmt.Fprintf(w, " %s %s\n", output.Label("Manual Code:"), output.Success(manual)) return nil diff --git a/cli/commission.go b/cli/commission.go index 423f830..066bcfa 100644 --- a/cli/commission.go +++ b/cli/commission.go @@ -49,6 +49,8 @@ func newCommissionCmd() *cobra.Command { } cmd.AddCommand(newCommissionCodeCmd()) cmd.AddCommand(newCommissionIPCmd()) + cmd.AddCommand(newCommissionOpenWindowCmd()) + cmd.AddCommand(newCommissionCloseWindowCmd()) addBLECommissionCommands(cmd) return cmd } diff --git a/cli/commission_window.go b/cli/commission_window.go new file mode 100644 index 0000000..baf5f8f --- /dev/null +++ b/cli/commission_window.go @@ -0,0 +1,517 @@ +// Copyright 2026 matter-cli contributors +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/p0fi/matter-cli/cli/output" + "github.com/p0fi/matter-cli/internal/clusters/administratorcommissioning" + "github.com/p0fi/matter-cli/internal/commissioning" + "github.com/p0fi/matter-cli/internal/daemon" + "github.com/p0fi/matter-cli/internal/interaction" + "github.com/p0fi/matter-cli/internal/protocol" + "github.com/p0fi/matter-cli/internal/tlv" + "github.com/p0fi/matter-cli/internal/store" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// timedInteractionTimeoutMs is the spec-mandated window for completing a Timed +// Invoke. The client sends TimedRequest with this timeout, and the server +// rejects the follow-up InvokeRequest if it arrives after it expires. It is +// unrelated to the commissioning window duration. +const timedInteractionTimeoutMs uint16 = 5_000 + +// Administrator Commissioning cluster-specific status codes (Matter spec §11.19.6). +const ( + adminBusy uint8 = 0x02 + adminPAKEParamError uint8 = 0x03 + adminWindowNotOpen uint8 = 0x04 +) + +// newCommissionOpenWindowCmd creates the `commission open-window` subcommand. +func newCommissionOpenWindowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "open-window", + Short: "Open an Enhanced Commissioning Method window on a commissioned device", + Long: `Ask an already-commissioned device to open a fresh commissioning window so a +second ecosystem (Apple Home, Google Home, Alexa, etc.) can commission it +without a factory reset. + +By default a unique passcode, salt, and discriminator are generated, the PAKE +verifier is derived locally, and the OpenCommissioningWindow command is sent +over a Timed Invoke. The resulting QR code and manual pairing code are printed +for use by the second ecosystem.`, + Example: ` matter @1 commission open-window + matter @1 commission open-window --timeout 5m + matter @1 commission open-window --passcode 20202021 --discriminator 3840 + matter @1 commission open-window --basic # Basic Commissioning Method`, + // Override the parent's daemon-guard: opening a window only invokes a + // command, it does not need exclusive DB access. Fall through to the + // root PersistentPreRunE for logging and target resolution. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return cmd.Root().PersistentPreRunE(cmd, args) + }, + RunE: runOpenWindow, + } + cmd.Flags().Duration("timeout", 3*time.Minute, "how long the window stays open (180s-900s)") + cmd.Flags().Uint32("iterations", uint32(commissioning.MinIterations), "PBKDF2 iteration count (1000-100000)") + cmd.Flags().Uint32("passcode", 0, "explicit 27-bit passcode (default: random)") + cmd.Flags().Uint16("discriminator", 0, "explicit 12-bit discriminator (default: random)") + cmd.Flags().String("salt-hex", "", "explicit hex-encoded PAKE salt, 16-32 bytes (default: random 16 bytes)") + cmd.Flags().Bool("basic", false, "use Basic Commissioning Method instead of ECM (passes no verifier)") + return cmd +} + +// newCommissionCloseWindowCmd creates the `commission close-window` subcommand. +func newCommissionCloseWindowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "close-window", + Aliases: []string{"revoke-window"}, + Short: "Revoke an open commissioning window on a commissioned device", + Example: ` matter @1 commission close-window`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return cmd.Root().PersistentPreRunE(cmd, args) + }, + RunE: runCloseWindow, + } + return cmd +} + +// runOpenWindow implements `commission open-window`. +func runOpenWindow(cmd *cobra.Command, _ []string) error { + nodeID, _, err := requireTarget(cmd) + if err != nil { + return err + } + + timeout, _ := cmd.Flags().GetDuration("timeout") + // Bounds-check in int64 seconds before narrowing to uint16 so that values + // larger than 65535 s cannot wrap into the valid [180,900] range. + timeoutSecsI64 := int64(timeout / time.Second) + if timeoutSecsI64 < int64(commissioning.MinCommissioningTimeoutSeconds) || + timeoutSecsI64 > int64(commissioning.MaxCommissioningTimeoutSeconds) { + return fmt.Errorf("--timeout must be between %ds and %ds", + commissioning.MinCommissioningTimeoutSeconds, + commissioning.MaxCommissioningTimeoutSeconds) + } + timeoutSecs := uint16(timeoutSecsI64) + + basic, _ := cmd.Flags().GetBool("basic") + + verbose, _ := cmd.Flags().GetBool("verbose") + stepper := output.NewStepper(cmd.OutOrStdout(), verbose) + + stepper.Step(fmt.Sprintf("Opening commissioning window on %s (timeout %s)", + output.Bold(resolveNodeLabel(nodeID)), output.Accent(timeout.String()))) + + // Resolve passcode, salt, discriminator (only needed for ECM). + passcode, salt, disc, err := resolveWindowParams(cmd, basic) + if err != nil { + stepper.Fail(err.Error()) + return err + } + + var fields []byte + var commandID uint32 + if basic { + commandID = administratorcommissioning.CmdOpenBasicCommissioningWindow + fields, err = encodeOpenBasicFields(timeoutSecs) + } else { + iterations, _ := cmd.Flags().GetUint32("iterations") + if iterations < uint32(commissioning.MinIterations) || iterations > uint32(commissioning.MaxIterations) { + err = fmt.Errorf("--iterations must be between %d and %d", + commissioning.MinIterations, commissioning.MaxIterations) + stepper.Fail(err.Error()) + return err + } + commandID = administratorcommissioning.CmdOpenCommissioningWindow + verifier, verr := commissioning.ComputePAKEVerifier(passcode, salt, int(iterations)) + if verr != nil { + stepper.Fail(verr.Error()) + return fmt.Errorf("computing PAKE verifier: %w", verr) + } + fields, err = encodeOpenWindowFields(timeoutSecs, verifier, disc, iterations, salt) + } + if err != nil { + stepper.Fail(err.Error()) + return fmt.Errorf("encoding request fields: %w", err) + } + + ctx := cmd.Context() + stepper.Step("Sending Timed Invoke") + if err := invokeAdminCommissioning(ctx, stepper, nodeID, commandID, fields); err != nil { + return err + } + + // Load VID/PID for the setup payload. Fall back to zero (still produces a + // valid QR/manual code, just with zero vendor info). + vid, pid := loadNodeVIDPID(ctx, nodeID, stepper) + + expiresAt := time.Now().Add(time.Duration(timeoutSecs) * time.Second) + + if basic { + stepper.Success(fmt.Sprintf("Basic commissioning window open until %s", + output.Accent(expiresAt.Format(time.RFC3339)))) + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", + output.Label("Expires:"), output.Accent(expiresAt.Format(time.RFC3339))) + fmt.Fprintf(cmd.OutOrStdout(), + " %s device's original setup code remains valid during this window\n", + output.Label("Pairing:")) + return nil + } + + payload := &commissioning.SetupPayload{ + VendorID: vid, + ProductID: pid, + Passcode: passcode, + Discriminator: disc, + CommissioningFlow: commissioning.FlowStandard, + DiscoveryCapabilities: commissioning.DiscoveryOnNetwork, + } + qr, qrErr := payload.QRCode() + if qrErr != nil { + return fmt.Errorf("building QR code: %w", qrErr) + } + manual, mErr := payload.ManualPairingCode() + if mErr != nil { + return fmt.Errorf("building manual pairing code: %w", mErr) + } + + stepper.Success(fmt.Sprintf("Commissioning window open until %s", + output.Accent(expiresAt.Format(time.RFC3339)))) + + w := cmd.OutOrStdout() + fmt.Fprintln(w) + if output.IsTTY() { + output.RenderQRCode(w, qr) + fmt.Fprintln(w) + } + fmt.Fprintf(w, " %s %s\n", output.Label("QR Code:"), output.Success(qr)) + fmt.Fprintf(w, " %s %s\n", output.Label("Manual Code:"), output.Success(manual)) + fmt.Fprintf(w, " %s %s\n", output.Label("Passcode:"), output.Accent(fmt.Sprintf("%d", passcode))) + fmt.Fprintf(w, " %s %s\n", output.Label("Discriminator:"), output.Accent(fmt.Sprintf("%d", disc))) + fmt.Fprintf(w, " %s %s\n", output.Label("Salt:"), output.Muted(hex.EncodeToString(salt))) + fmt.Fprintf(w, " %s %s\n", output.Label("Expires:"), output.Accent(expiresAt.Format(time.RFC3339))) + return nil +} + +// runCloseWindow implements `commission close-window`. +func runCloseWindow(cmd *cobra.Command, _ []string) error { + nodeID, _, err := requireTarget(cmd) + if err != nil { + return err + } + + verbose, _ := cmd.Flags().GetBool("verbose") + stepper := output.NewStepper(cmd.OutOrStdout(), verbose) + + stepper.Step(fmt.Sprintf("Revoking commissioning window on %s", + output.Bold(resolveNodeLabel(nodeID)))) + + if err := invokeAdminCommissioning(cmd.Context(), stepper, nodeID, + administratorcommissioning.CmdRevokeCommissioning, nil); err != nil { + return err + } + + stepper.Success("Commissioning window revoked") + return nil +} + +// resolveWindowParams gathers (or generates) the per-window PAKE parameters. +// For Basic Commissioning these values are not used by the device, so we keep +// the logic uniform and just skip generation. +func resolveWindowParams(cmd *cobra.Command, basic bool) (uint32, []byte, uint16, error) { + if basic { + return 0, nil, 0, nil + } + + passcode, _ := cmd.Flags().GetUint32("passcode") + if cmd.Flags().Changed("passcode") { + if err := commissioning.ValidatePasscode(passcode); err != nil { + return 0, nil, 0, err + } + } else { + p, err := commissioning.GenerateRandomPasscode() + if err != nil { + return 0, nil, 0, fmt.Errorf("generating random passcode: %w", err) + } + passcode = p + } + + disc, _ := cmd.Flags().GetUint16("discriminator") + if !cmd.Flags().Changed("discriminator") { + d, err := commissioning.GenerateRandomDiscriminator() + if err != nil { + return 0, nil, 0, fmt.Errorf("generating random discriminator: %w", err) + } + disc = d + } else if disc > commissioning.MaxDiscriminator { + return 0, nil, 0, fmt.Errorf("--discriminator %d exceeds 12 bits", disc) + } + + var salt []byte + if saltHex, _ := cmd.Flags().GetString("salt-hex"); saltHex != "" { + b, err := hex.DecodeString(strings.TrimPrefix(saltHex, "0x")) + if err != nil { + return 0, nil, 0, fmt.Errorf("--salt-hex is not valid hex: %w", err) + } + if len(b) < commissioning.MinSaltLength || len(b) > commissioning.MaxSaltLength { + return 0, nil, 0, fmt.Errorf("--salt-hex length %d outside [%d,%d]", + len(b), commissioning.MinSaltLength, commissioning.MaxSaltLength) + } + salt = b + } else { + s, err := commissioning.GenerateRandomSalt(commissioning.MinSaltLength) + if err != nil { + return 0, nil, 0, fmt.Errorf("generating random salt: %w", err) + } + salt = s + } + + return passcode, salt, disc, nil +} + +// encodeOpenWindowFields builds the TLV payload for OpenCommissioningWindow. +// Tags (per AdministratorCommissioning spec): +// +// 0: CommissioningTimeout (uint16) +// 1: PAKEPasscodeVerifier (octets, 97 bytes) +// 2: Discriminator (uint16) +// 3: Iterations (uint32) +// 4: Salt (octets) +func encodeOpenWindowFields(timeoutSecs uint16, verifier []byte, disc uint16, iterations uint32, salt []byte) ([]byte, error) { + w := tlv.NewWriter() + if err := w.PutUnsignedInt(tlv.ContextTag(0), uint64(timeoutSecs)); err != nil { + return nil, err + } + if err := w.PutOctetString(tlv.ContextTag(1), verifier); err != nil { + return nil, err + } + if err := w.PutUnsignedInt(tlv.ContextTag(2), uint64(disc)); err != nil { + return nil, err + } + if err := w.PutUnsignedInt(tlv.ContextTag(3), uint64(iterations)); err != nil { + return nil, err + } + if err := w.PutOctetString(tlv.ContextTag(4), salt); err != nil { + return nil, err + } + return w.Bytes(), nil +} + +// encodeOpenBasicFields builds the TLV payload for OpenBasicCommissioningWindow. +// Tag 0: CommissioningTimeout (uint16). +func encodeOpenBasicFields(timeoutSecs uint16) ([]byte, error) { + w := tlv.NewWriter() + if err := w.PutUnsignedInt(tlv.ContextTag(0), uint64(timeoutSecs)); err != nil { + return nil, err + } + return w.Bytes(), nil +} + +// invokeAdminCommissioning issues a Timed Invoke of the given Administrator +// Commissioning command on endpoint 0. It prefers the session daemon when +// available (reusing an already-established CASE session) and falls back to +// a direct CASE connection. Cluster-specific status codes are decoded into +// human-friendly errors. +func invokeAdminCommissioning(ctx context.Context, stepper *output.Stepper, nodeID uint64, commandID uint32, fields []byte) error { + const ( + endpoint uint16 = 0 + clusterID uint32 = administratorcommissioning.ID + ) + + if dc, ok := connectViaDaemon(nodeID); ok { + slog.Debug("using session daemon for admin commissioning invoke", "node", nodeID, "cmd", commandID) + stepper.Step("Sending Timed Invoke " + output.Muted("(via session daemon)")) + resp, err := dc.Invoke(endpoint, clusterID, commandID, fields, timedInteractionTimeoutMs) + if err != nil { + stepper.Fail(fmt.Sprintf("Invoke failed: %v", err)) + return fmt.Errorf("invoking command: %w", err) + } + if resp.StatusCode != 0 || resp.ClusterStatus != nil { + return handleAdminStatus(stepper, commandID, resp.StatusCode, resp.ClusterStatus) + } + return nil + } + + client, session, cleanup, err := connectToNode(ctx, nodeID) + if err != nil { + stepper.Fail(fmt.Sprintf("Failed to connect: %v", err)) + return err + } + defer cleanup() + + return invokeAdminCommissioningDirect(ctx, stepper, client, session, endpoint, clusterID, commandID, fields) +} + +// invokeAdminCommissioningDirect runs a Timed Invoke over a caller-owned CASE +// session and decodes the response, mapping cluster-specific status codes to +// friendly errors. +func invokeAdminCommissioningDirect(ctx context.Context, stepper *output.Stepper, client *interaction.Client, session *protocol.Session, endpoint uint16, clusterID, commandID uint32, fields []byte) error { + path := interaction.NewCommandPath(endpoint, clusterID, commandID) + resp, err := client.InvokeTimed(ctx, session, path, fields, timedInteractionTimeoutMs) + if err != nil { + stepper.Fail(fmt.Sprintf("Invoke failed: %v", err)) + return fmt.Errorf("invoking command: %w", err) + } + if resp.Status != nil { + st := resp.Status.Status + if st.Status != 0 || st.ClusterStatus != nil { + return handleAdminStatus(stepper, commandID, st.Status, st.ClusterStatus) + } + } + return nil +} + +// handleAdminStatus turns IM / cluster-specific status codes from the +// Administrator Commissioning cluster into user-friendly errors. Returns nil +// for an explicit Success (0,nil). +func handleAdminStatus(stepper *output.Stepper, commandID uint32, imStatus uint8, clusterStatus *uint8) error { + if imStatus == 0 && clusterStatus == nil { + return nil + } + + var msg string + switch { + case clusterStatus != nil: + switch *clusterStatus { + case adminBusy: + msg = "device is busy (Busy) — a commissioning window is already open" + case adminPAKEParamError: + msg = "device rejected the PAKE parameters (PAKEParameterError)" + case adminWindowNotOpen: + if commandID == administratorcommissioning.CmdRevokeCommissioning { + msg = "no commissioning window is currently open (WindowNotOpen)" + } else { + msg = "WindowNotOpen" + } + default: + msg = fmt.Sprintf("cluster status 0x%02X", *clusterStatus) + } + default: + msg = fmt.Sprintf("%s (0x%02X)", interaction.StatusCode(imStatus), imStatus) + } + + stepper.Fail(fmt.Sprintf("Command failed: %s", msg)) + return fmt.Errorf("%s", msg) +} + +// loadNodeVIDPID returns the stored VendorID/ProductID for a node, falling back +// to reading BasicInformation over CASE when they are not persisted. Read +// errors are non-fatal: the returned values default to zero. +func loadNodeVIDPID(ctx context.Context, nodeID uint64, stepper *output.Stepper) (uint16, uint16) { + fabricID := viper.GetUint64("default-fabric-id") + if fabricID == 0 { + fabricID = 1 + } + + var node *store.Node + if n, err := getNodeForCompletion(fabricID, nodeID); err == nil { + node = n + } + if node != nil && (node.VendorID != 0 || node.ProductID != 0) { + return node.VendorID, node.ProductID + } + + stepper.Step("Reading BasicInformation VID/PID") + vid, pid, err := readBasicInfoVIDPID(ctx, nodeID) + if err != nil { + slog.Debug("could not read BasicInformation VID/PID", "node", nodeID, "err", err) + return 0, 0 + } + return vid, pid +} + +// readBasicInfoVIDPID reads VendorID (0x0002) and ProductID (0x0004) from +// BasicInformation (cluster 0x0028, endpoint 0). Tries the daemon first, then +// a direct CASE session. +func readBasicInfoVIDPID(ctx context.Context, nodeID uint64) (uint16, uint16, error) { + const ( + basicInfo = uint32(0x0028) + attrVID = uint32(0x0002) + attrPID = uint32(0x0004) + ep0 = uint16(0) + ) + + if dc, ok := connectViaDaemon(nodeID); ok { + resp, err := dc.Read( + daemon.AttrPathReq{Endpoint: ep0, ClusterID: basicInfo, AttributeID: attrVID}, + daemon.AttrPathReq{Endpoint: ep0, ClusterID: basicInfo, AttributeID: attrPID}, + ) + if err != nil { + return 0, 0, fmt.Errorf("daemon read: %w", err) + } + var vid, pid uint16 + for _, r := range resp.Reports { + if r.StatusCode != 0 { + continue + } + data, derr := daemon.DecodeFields(r.Data) + if derr != nil { + continue + } + switch r.AttributeID { + case attrVID: + vid = decodeTLVUint16(data) + case attrPID: + pid = decodeTLVUint16(data) + } + } + return vid, pid, nil + } + + client, session, cleanup, err := connectToNode(ctx, nodeID) + if err != nil { + return 0, 0, err + } + defer cleanup() + + reports, err := client.Read(ctx, session, + interaction.NewAttributePath(ep0, basicInfo, attrVID), + interaction.NewAttributePath(ep0, basicInfo, attrPID), + ) + if err != nil { + return 0, 0, err + } + var vid, pid uint16 + for _, r := range reports { + if r.Data == nil || r.Data.Path.AttributeID == nil { + continue + } + switch *r.Data.Path.AttributeID { + case attrVID: + vid = decodeTLVUint16(r.Data.Data) + case attrPID: + pid = decodeTLVUint16(r.Data.Data) + } + } + return vid, pid, nil +} + +// decodeTLVUint16 extracts a uint16 from a raw TLV element. +func decodeTLVUint16(data []byte) uint16 { + if len(data) == 0 { + return 0 + } + r := tlv.NewReader(bytes.NewReader(data)) + if err := r.Next(); err != nil { + return 0 + } + switch v := r.Value().(type) { + case uint64: + return uint16(v) + case int64: + return uint16(v) + } + return 0 +} diff --git a/cli/output/qrcode.go b/cli/output/qrcode.go new file mode 100644 index 0000000..9ed4aa1 --- /dev/null +++ b/cli/output/qrcode.go @@ -0,0 +1,39 @@ +// Copyright 2026 matter-cli contributors +// SPDX-License-Identifier: Apache-2.0 + +package output + +import ( + "io" + + "github.com/mdp/qrterminal/v3" + "rsc.io/qr" +) + +// RenderQRCode writes a scannable 2D QR code for payload to w using Unicode +// half-block characters (two QR modules per terminal cell). Returns false when +// no visual QR was emitted — callers may still want to print the textual code. +// +// The QR uses error-correction level M (matches Matter controllers such as +// Apple Home / Google Home which also encode at level M) and includes the +// standard 4-module quiet zone so phone-camera commissioners can lock onto it. +func RenderQRCode(w io.Writer, payload string) bool { + if payload == "" { + return false + } + cfg := qrterminal.Config{ + Level: qr.M, + Writer: w, + HalfBlocks: true, + QuietZone: 2, + // Half-block glyphs rely on the terminal's own foreground / background + // colors, so they render correctly under NO_COLOR without further + // special-casing. + BlackChar: qrterminal.BLACK_BLACK, + WhiteChar: qrterminal.WHITE_WHITE, + BlackWhiteChar: qrterminal.BLACK_WHITE, + WhiteBlackChar: qrterminal.WHITE_BLACK, + } + qrterminal.GenerateWithConfig(payload, cfg) + return true +} diff --git a/go.mod b/go.mod index 0559811..282a033 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mazznoer/csscolorparser v0.1.5 // indirect + github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/miekg/dns v1.1.27 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -65,9 +66,11 @@ require ( golang.org/x/image v0.20.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect oss.terrastruct.com/util-go v0.0.0-20250213174338-243d8661088a // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 536be44..6d1ee21 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mazznoer/csscolorparser v0.1.5 h1:Wr4uNIE+pHWN3TqZn2SGpA2nLRG064gB7WdSfSS5cz4= github.com/mazznoer/csscolorparser v0.1.5/go.mod h1:OQRVvgCyHDCAquR1YWfSwwaDcM0LhnSffGnlbOew/3I= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -184,6 +186,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -212,5 +216,7 @@ oss.terrastruct.com/d2 v0.7.1 h1:LafTW1UoXJGODvKDZ8obyBfGcc2k2vHZ3EzrabMqEVE= oss.terrastruct.com/d2 v0.7.1/go.mod h1:aT0PwLaxBZGgsWrIT8oSFYm5xoYX08BaOHewi5qLE2E= oss.terrastruct.com/util-go v0.0.0-20250213174338-243d8661088a h1:UXF/Z9i9tOx/wqGUOn/T12wZeez1Gg0sAVKKl7YUDwM= oss.terrastruct.com/util-go v0.0.0-20250213174338-243d8661088a/go.mod h1:eMWv0sOtD9T2RUl90DLWfuShZCYp4NrsqNpI8eqO6U4= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= tinygo.org/x/bluetooth v0.14.0 h1:rrUaT+Fu6O0phGm4Y5UZULL8F7UahOq/JwGAPjJm+V4= tinygo.org/x/bluetooth v0.14.0/go.mod h1:YnyJRVX09i+wkFeHpXut0b+qHq+T2WwKBRRiF/scANA= diff --git a/internal/commissioning/verifier.go b/internal/commissioning/verifier.go new file mode 100644 index 0000000..73de25a --- /dev/null +++ b/internal/commissioning/verifier.go @@ -0,0 +1,129 @@ +// Copyright 2026 matter-cli contributors +// SPDX-License-Identifier: Apache-2.0 + +package commissioning + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + + "github.com/p0fi/matter-cli/internal/crypto" +) + +// PAKE parameter constraints from Matter spec §6 (AdministratorCommissioning). +const ( + // PAKEVerifierSize is the byte length of the (w0 || L) PAKE passcode verifier. + PAKEVerifierSize = 97 + // PAKEVerifierW0Size is the size in bytes of the w0 scalar portion of the verifier. + PAKEVerifierW0Size = 32 + // PAKEVerifierLSize is the size in bytes of the uncompressed L point portion. + PAKEVerifierLSize = 65 + + // MinIterations is the minimum permitted PAKE iteration count (Crypto_PBKDFParameterSet). + MinIterations = 1000 + // MaxIterations is the maximum permitted PAKE iteration count. + MaxIterations = 100000 + + // MinSaltLength is the minimum permitted PAKE salt length in bytes. + MinSaltLength = 16 + // MaxSaltLength is the maximum permitted PAKE salt length in bytes. + MaxSaltLength = 32 + + // MaxPasscode is the largest permitted 27-bit setup passcode. + MaxPasscode uint32 = 0x7FFFFFF + // MaxDiscriminator is the largest permitted 12-bit discriminator. + MaxDiscriminator uint16 = 0xFFF + + // MinCommissioningTimeoutSeconds is the minimum commissioning window timeout (spec: 180s). + MinCommissioningTimeoutSeconds uint16 = 180 + // MaxCommissioningTimeoutSeconds is the maximum commissioning window timeout (spec: 900s). + MaxCommissioningTimeoutSeconds uint16 = 900 +) + +// ComputePAKEVerifier derives the 97-byte (w0 || L) PAKE passcode verifier for +// an Enhanced Commissioning Method OpenCommissioningWindow invocation. +// +// The returned slice is the concatenation of the 32-byte w0 scalar (derived from +// the passcode via PBKDF2) and the 65-byte uncompressed L = w1 * P point. +func ComputePAKEVerifier(passcode uint32, salt []byte, iterations int) ([]byte, error) { + if err := ValidatePasscode(passcode); err != nil { + return nil, err + } + if len(salt) < MinSaltLength || len(salt) > MaxSaltLength { + return nil, fmt.Errorf("commissioning: salt length %d outside [%d,%d]", + len(salt), MinSaltLength, MaxSaltLength) + } + if iterations < MinIterations || iterations > MaxIterations { + return nil, fmt.Errorf("commissioning: iterations %d outside [%d,%d]", + iterations, MinIterations, MaxIterations) + } + + w0, w1, err := crypto.DeriveSPAKE2PValues(passcode, salt, iterations) + if err != nil { + return nil, fmt.Errorf("deriving SPAKE2+ values: %w", err) + } + L := crypto.ComputeL(w1) + + if len(w0) != PAKEVerifierW0Size { + return nil, fmt.Errorf("commissioning: unexpected w0 size %d, want %d", len(w0), PAKEVerifierW0Size) + } + if len(L) != PAKEVerifierLSize { + return nil, fmt.Errorf("commissioning: unexpected L size %d, want %d", len(L), PAKEVerifierLSize) + } + + verifier := make([]byte, 0, PAKEVerifierSize) + verifier = append(verifier, w0...) + verifier = append(verifier, L...) + return verifier, nil +} + +// ValidatePasscode reports whether a setup passcode is valid per the Matter +// spec. It rejects 0, values exceeding 27 bits, and the 11 disallowed trivial +// sequences listed in the spec. +func ValidatePasscode(passcode uint32) error { + if passcode > MaxPasscode { + return fmt.Errorf("commissioning: passcode %d exceeds 27 bits", passcode) + } + return validatePasscode(passcode) +} + +// GenerateRandomPasscode returns a cryptographically random 27-bit passcode +// that passes ValidatePasscode. It retries until it lands on an allowed value. +func GenerateRandomPasscode() (uint32, error) { + var buf [4]byte + for i := 0; i < 16; i++ { + if _, err := rand.Read(buf[:]); err != nil { + return 0, fmt.Errorf("commissioning: reading random passcode: %w", err) + } + candidate := binary.BigEndian.Uint32(buf[:]) & MaxPasscode + if ValidatePasscode(candidate) == nil { + return candidate, nil + } + } + return 0, fmt.Errorf("commissioning: failed to generate random passcode") +} + +// GenerateRandomSalt returns a cryptographically random PAKE salt of the +// requested length. Length must be within [MinSaltLength, MaxSaltLength]. +func GenerateRandomSalt(length int) ([]byte, error) { + if length < MinSaltLength || length > MaxSaltLength { + return nil, fmt.Errorf("commissioning: salt length %d outside [%d,%d]", + length, MinSaltLength, MaxSaltLength) + } + salt := make([]byte, length) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("commissioning: reading random salt: %w", err) + } + return salt, nil +} + +// GenerateRandomDiscriminator returns a cryptographically random 12-bit +// discriminator. +func GenerateRandomDiscriminator() (uint16, error) { + var buf [2]byte + if _, err := rand.Read(buf[:]); err != nil { + return 0, fmt.Errorf("commissioning: reading random discriminator: %w", err) + } + return binary.BigEndian.Uint16(buf[:]) & MaxDiscriminator, nil +} diff --git a/internal/commissioning/verifier_test.go b/internal/commissioning/verifier_test.go new file mode 100644 index 0000000..656c883 --- /dev/null +++ b/internal/commissioning/verifier_test.go @@ -0,0 +1,163 @@ +// Copyright 2026 matter-cli contributors +// SPDX-License-Identifier: Apache-2.0 + +package commissioning + +import ( + "bytes" + "testing" + + "github.com/p0fi/matter-cli/internal/crypto" +) + +func TestComputePAKEVerifier(t *testing.T) { + const ( + passcode = uint32(20202021) + iterations = 1000 + ) + salt := []byte("SPAKE2P Key Salt") + + verifier, err := ComputePAKEVerifier(passcode, salt, iterations) + if err != nil { + t.Fatalf("ComputePAKEVerifier: %v", err) + } + + if len(verifier) != PAKEVerifierSize { + t.Fatalf("verifier length %d, want %d", len(verifier), PAKEVerifierSize) + } + + // Cross-check against the primitive derivation to guard against regressions + // in ComputePAKEVerifier's composition. + w0, w1, err := crypto.DeriveSPAKE2PValues(passcode, salt, iterations) + if err != nil { + t.Fatalf("DeriveSPAKE2PValues: %v", err) + } + L := crypto.ComputeL(w1) + + if !bytes.Equal(verifier[:PAKEVerifierW0Size], w0) { + t.Errorf("verifier[0:32] does not match w0") + } + if !bytes.Equal(verifier[PAKEVerifierW0Size:], L) { + t.Errorf("verifier[32:97] does not match L") + } + + // L must be an uncompressed SEC1 point: leading 0x04 and 64 bytes of X||Y. + if verifier[PAKEVerifierW0Size] != 0x04 { + t.Errorf("L should start with 0x04 (uncompressed point tag), got 0x%02X", + verifier[PAKEVerifierW0Size]) + } +} + +func TestComputePAKEVerifierDeterministic(t *testing.T) { + salt := []byte("deterministic-salt-16b") + a, err := ComputePAKEVerifier(11223344, salt, MinIterations) + if err != nil { + t.Fatalf("first call: %v", err) + } + b, err := ComputePAKEVerifier(11223344, salt, MinIterations) + if err != nil { + t.Fatalf("second call: %v", err) + } + if !bytes.Equal(a, b) { + t.Errorf("verifier should be deterministic for the same inputs") + } +} + +func TestComputePAKEVerifierRejectsInvalidInputs(t *testing.T) { + validSalt := make([]byte, MinSaltLength) + + cases := []struct { + name string + passcode uint32 + salt []byte + iterations int + }{ + {"passcode zero", 0, validSalt, MinIterations}, + {"passcode disallowed", 11111111, validSalt, MinIterations}, + {"passcode exceeds 27 bits", MaxPasscode + 1, validSalt, MinIterations}, + {"salt too short", 12345, make([]byte, MinSaltLength-1), MinIterations}, + {"salt too long", 12345, make([]byte, MaxSaltLength+1), MinIterations}, + {"iterations too low", 12345, validSalt, MinIterations - 1}, + {"iterations too high", 12345, validSalt, MaxIterations + 1}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := ComputePAKEVerifier(tc.passcode, tc.salt, tc.iterations); err == nil { + t.Errorf("expected error for %s, got nil", tc.name) + } + }) + } +} + +func TestValidatePasscode(t *testing.T) { + invalid := []uint32{ + 0, 11111111, 22222222, 33333333, 44444444, 55555555, + 66666666, 77777777, 88888888, 99999999, 12345678, 87654321, + } + for _, p := range invalid { + if err := ValidatePasscode(p); err == nil { + t.Errorf("passcode %d should be invalid", p) + } + } + valid := []uint32{1, 20202021, 33221100, 12345679} + for _, p := range valid { + if err := ValidatePasscode(p); err != nil { + t.Errorf("passcode %d should be valid: %v", p, err) + } + } + if err := ValidatePasscode(MaxPasscode + 1); err == nil { + t.Errorf("passcode exceeding 27 bits should be rejected") + } +} + +func TestGenerateRandomPasscode(t *testing.T) { + seen := make(map[uint32]struct{}) + for i := 0; i < 32; i++ { + p, err := GenerateRandomPasscode() + if err != nil { + t.Fatalf("GenerateRandomPasscode: %v", err) + } + if err := ValidatePasscode(p); err != nil { + t.Fatalf("generated passcode %d failed validation: %v", p, err) + } + if p > MaxPasscode { + t.Fatalf("generated passcode %d exceeds 27 bits", p) + } + seen[p] = struct{}{} + } + // Extremely unlikely to collide on every call — guard against a stuck RNG. + if len(seen) < 2 { + t.Errorf("expected diverse random passcodes, got only %d unique", len(seen)) + } +} + +func TestGenerateRandomSalt(t *testing.T) { + for _, size := range []int{MinSaltLength, 20, MaxSaltLength} { + salt, err := GenerateRandomSalt(size) + if err != nil { + t.Fatalf("GenerateRandomSalt(%d): %v", size, err) + } + if len(salt) != size { + t.Errorf("salt length %d, want %d", len(salt), size) + } + } + if _, err := GenerateRandomSalt(MinSaltLength - 1); err == nil { + t.Errorf("expected error for salt size below minimum") + } + if _, err := GenerateRandomSalt(MaxSaltLength + 1); err == nil { + t.Errorf("expected error for salt size above maximum") + } +} + +func TestGenerateRandomDiscriminator(t *testing.T) { + for i := 0; i < 16; i++ { + d, err := GenerateRandomDiscriminator() + if err != nil { + t.Fatalf("GenerateRandomDiscriminator: %v", err) + } + if d > MaxDiscriminator { + t.Fatalf("discriminator %d exceeds 12 bits", d) + } + } +} diff --git a/internal/daemon/protocol.go b/internal/daemon/protocol.go index e90f82b..238aa9c 100644 --- a/internal/daemon/protocol.go +++ b/internal/daemon/protocol.go @@ -141,6 +141,9 @@ type FabricResp struct { type InvokeResp struct { // StatusCode is the IM status code. 0 means success. StatusCode uint8 `json:"status_code"` + // ClusterStatus is the cluster-specific status code when StatusCode is + // Failure (0x01). Nil when the response carried no cluster status. + ClusterStatus *uint8 `json:"cluster_status,omitempty"` // Data is the base64-encoded raw TLV response fields, if any. Data string `json:"data,omitempty"` // HasData is true when the invoke returned response data (as opposed to diff --git a/internal/daemon/server.go b/internal/daemon/server.go index a5e2ebc..a15b30e 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -489,6 +489,7 @@ func (s *Server) handleInvoke(ctx context.Context, req *Request) Response { invokeResp := &InvokeResp{} if resp.Status != nil { invokeResp.StatusCode = resp.Status.Status.Status + invokeResp.ClusterStatus = resp.Status.Status.ClusterStatus } if resp.Command != nil && len(resp.Command.Fields) > 0 { invokeResp.HasData = true